From ade45c33261a65efe597576ba9ed1288b9adf81e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:24:59 +0000 Subject: [PATCH 001/115] feat(concord): add Concord key-derivation crypto foundation Introduce the quartz `concord/crypto` package implementing the interoperable key-derivation core of the Concord protocol (encrypted, serverless communities on Nostr), pinned to the Concord v2 reference client (Soapbox Armada) for wire compatibility: - ConcordLabels: frozen HKDF domain-separation labels (CORD-01..07) - ConcordKeyDerivation: buildInfo layout, hkdf32, scalar-normalizing deriveSecretKey, groupKey (plane/channel address + self-ECDH conv key), communityId, voice keys, and rekey recipient locator - GroupKey: plane key result (secret key, x-only address, conversation key) - EditionHash: domain-separated, length-prefixed edition-chain hash (CORD-04) Reuses the in-tree Hkdf, Nip44v2 self-ECDH, KeyPair and Secp256k1Instance primitives. Adds property-based tests (determinism, distinctness across label/id/epoch/secret, self-ECDH round-trip, genesis-vs-zero-prev chain, verbatim-content hashing). All green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/crypto/ConcordKeyDerivation.kt | 212 ++++++++++++++++++ .../quartz/concord/crypto/ConcordLabels.kt | 76 +++++++ .../quartz/concord/crypto/EditionHash.kt | 97 ++++++++ .../quartz/concord/crypto/GroupKey.kt | 47 ++++ .../crypto/ConcordKeyDerivationTest.kt | 169 ++++++++++++++ .../quartz/concord/crypto/EditionHashTest.kt | 73 ++++++ 6 files changed, 674 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt new file mode 100644 index 0000000000..d63f3b21ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -0,0 +1,212 @@ +/* + * 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.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Concord key derivation (CORD-02 Appendix A, CORD-03, CORD-06, CORD-07). + * + * Everything a Concord community needs is derived deterministically from a small + * number of secrets so that "only holders of the secret can derive a plane's + * address, and only members can produce events at it." All of this is pinned to + * the Concord v2 reference client (Armada) for wire interoperability — see + * [ConcordLabels] for the frozen label strings. + * + * The core primitive is [groupKey]. Its info layout is + * `utf8(label) ‖ 0x00 ‖ id[32]? ‖ epoch_be8?` fed into `HKDF-SHA256(ikm=secret, + * salt=zeros(32), info, L=32)`, with a counter-byte retry ([deriveSecretKey]) on + * the astronomically rare chance the 32-byte output is not a valid secp256k1 + * scalar. + */ +object ConcordKeyDerivation { + private val hkdf = Hkdf() + + /** RFC 5869 "salt not provided" — HashLen (32) zero bytes. */ + private val ZERO_SALT = ByteArray(32) + + /** + * Builds the HKDF `info` for a plane/channel derivation: + * `utf8(label) ‖ 0x00 ‖ id[32]? ‖ epoch_be8?`. + * + * [id] is appended verbatim when present (32 bytes for community/channel ids, + * or `sha256(identity)` for voice-sender keys). [epoch] is appended as a + * big-endian unsigned 64-bit integer when present, and omitted otherwise. + */ + fun buildInfo( + label: String, + id: ByteArray? = null, + epoch: Long? = null, + ): ByteArray { + val labelBytes = label.encodeToByteArray() + val idLen = id?.size ?: 0 + val epochLen = if (epoch != null) 8 else 0 + val out = ByteArray(labelBytes.size + 1 + idLen + epochLen) + var pos = 0 + labelBytes.copyInto(out, pos) + pos += labelBytes.size + out[pos] = 0x00 + pos += 1 + if (id != null) { + id.copyInto(out, pos) + pos += id.size + } + if (epoch != null) { + writeBe64(out, pos, epoch) + } + return out + } + + /** `HKDF-SHA256(ikm=secret, salt=zeros(32), info, L=32)` — a raw 32-byte output. */ + fun hkdf32( + secret: ByteArray, + info: ByteArray, + ): ByteArray { + val prk = hkdf.extract(secret, ZERO_SALT) + return hkdf.expand(prk, info, 32) + } + + /** + * Derives a valid secp256k1 secret key from [secret] and [info]. + * + * Runs [hkdf32]; if the result is not a valid scalar (0 or ≥ curve order — + * probability ≈ 2⁻¹²⁸), appends an incrementing counter byte (0…255) to the + * info and retries, matching the reference client's deterministic fallback. + */ + fun deriveSecretKey( + secret: ByteArray, + info: ByteArray, + ): ByteArray { + val first = hkdf32(secret, info) + if (Secp256k1Instance.isPrivateKeyValid(first)) return first + + val extended = ByteArray(info.size + 1) + info.copyInto(extended) + var counter = 0 + while (counter <= 255) { + extended[info.size] = counter.toByte() + val candidate = hkdf32(secret, extended) + if (Secp256k1Instance.isPrivateKeyValid(candidate)) return candidate + counter++ + } + throw IllegalStateException("Unable to derive a valid secp256k1 scalar for the given secret/info") + } + + /** + * The core Concord derivation: turns a shared [secret] into a plane/channel + * [GroupKey] (secret key, x-only public address, and self-ECDH NIP-44 + * conversation key) at the given [id] and [epoch]. + */ + fun groupKey( + label: String, + secret: ByteArray, + id: ByteArray? = null, + epoch: Long? = null, + ): GroupKey { + val sk = deriveSecretKey(secret, buildInfo(label, id, epoch)) + val keyPair = KeyPair(privKey = sk) + val conversationKey = Nip44.v2.getConversationKey(sk, keyPair.pubKey) + return GroupKey(sk, keyPair.pubKey, conversationKey) + } + + /** + * The permanent, self-certifying community id (CORD-02): + * `sha256("concord/community" ‖ owner_xonly ‖ owner_salt)`. + * + * A plain SHA-256 commitment (not HKDF-shaped), so a bundle can never smuggle + * a false owner or a fake key for a real community. + */ + fun communityId( + ownerXOnly: ByteArray, + ownerSalt: ByteArray, + ): ByteArray { + val prefix = ConcordLabels.COMMUNITY.encodeToByteArray() + val preimage = ByteArray(prefix.size + ownerXOnly.size + ownerSalt.size) + prefix.copyInto(preimage, 0) + ownerXOnly.copyInto(preimage, prefix.size) + ownerSalt.copyInto(preimage, prefix.size + ownerXOnly.size) + return sha256(preimage) + } + + /** Fresh 32-byte owner salt, generated once at community creation (CORD-02). */ + fun newOwnerSalt(): ByteArray = RandomInstance.bytes(32) + + // ---- CORD-07 voice keys (all ride the Channel's epoch) -------------------- + + /** Voice signer keypair; its x-only public key is the SFU room name (CORD-07 §1). */ + fun voiceSignerKey( + channelSecret: ByteArray, + channelId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.VOICE_SIGNER, channelSecret, channelId, epoch) + + /** 32-byte voice media root; per-sender frame keys derive from it (CORD-07 §1). */ + fun voiceMediaKey( + channelSecret: ByteArray, + channelId: ByteArray, + epoch: Long, + ): ByteArray = hkdf32(channelSecret, buildInfo(ConcordLabels.VOICE_MEDIA, channelId, epoch)) + + /** + * Per-sender voice frame key (CORD-07 §3): + * `hkdf32(voice_media_key, "concord/voice-sender" ‖ 0x00 ‖ sha256(utf8(identity)))`. + * There is no epoch field — the media key already rides the epoch. + */ + fun voiceSenderKey( + voiceMediaKey: ByteArray, + identity: String, + ): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray()))) + + // ---- CORD-06 rekey locator ------------------------------------------------ + + /** + * Recipient locator / pseudonym for a rekey blob (CORD-06 §2). Derived purely + * from public inputs (`rotator_xonly ‖ recipient_xonly` as IKM), so bunker + * accounts can locate their blob without touching raw keys. + */ + fun recipientLocator( + rotatorXOnly: ByteArray, + recipientXOnly: ByteArray, + scopeId: ByteArray, + epoch: Long, + ): ByteArray { + val ikm = ByteArray(rotatorXOnly.size + recipientXOnly.size) + rotatorXOnly.copyInto(ikm, 0) + recipientXOnly.copyInto(ikm, rotatorXOnly.size) + return hkdf32(ikm, buildInfo(ConcordLabels.RECIPIENT_PSEUDONYM, scopeId, epoch)) + } + + /** Writes [value] as a big-endian unsigned 64-bit integer into [out] at [offset]. */ + internal fun writeBe64( + out: ByteArray, + offset: Int, + value: Long, + ) { + for (i in 0 until 8) { + out[offset + i] = (value ushr (8 * (7 - i))).toByte() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt new file mode 100644 index 0000000000..cf6f57527b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -0,0 +1,76 @@ +/* + * 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.quartz.concord.crypto + +/** + * Frozen domain-separation labels used across the Concord protocol (CORD-01…07). + * + * These strings are part of the wire contract: every implementation must feed the + * exact same UTF-8 bytes into HKDF for members to derive matching plane keys. They + * are pinned to the Concord v2 reference client (Soapbox's Armada, `concord-v2`), + * which is the interoperability target. Do not rename or re-case them. + */ +object ConcordLabels { + /** Prefix for the SHA-256 community-id commitment (CORD-02). Not an HKDF label. */ + const val COMMUNITY = "concord/community" + + /** Per-Channel Chat Plane key (CORD-03). */ + const val CHANNEL = "concord/channel" + + /** Control Plane key (CORD-02). */ + const val CONTROL = "concord/control" + + /** Guestbook Plane key (CORD-02). */ + const val GUESTBOOK = "concord/guestbook" + + /** Grant coordinate derivation (CORD-04). */ + const val GRANT = "concord/grant" + + /** Banlist coordinate derivation (CORD-04). */ + const val BANLIST = "concord/banlist" + + /** Invite-link coordinate derivation (CORD-05). */ + const val INVITE_LINKS = "concord/invite-links" + + /** Invite bundle decryption key from the unlock token (CORD-05). */ + const val INVITE_KEY = "concord/invite-key" + + /** Dissolution tombstone coordinate (CORD-02). */ + const val DISSOLVED = "concord/dissolved" + + /** Voice signer keypair — public key is the SFU room name (CORD-07). */ + const val VOICE_SIGNER = "concord/voice-signer" + + /** Voice media root key (CORD-07). */ + const val VOICE_MEDIA = "concord/voice-media" + + /** Per-sender voice frame key (CORD-07). No epoch field in the info. */ + const val VOICE_SENDER = "concord/voice-sender" + + /** Rekey recipient pseudonym / locator (CORD-06). */ + const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym" + + /** Channel-scoped rekey pseudonym (CORD-06). */ + const val REKEY_PSEUDONYM = "concord/rekey-pseudonym" + + /** community_root-scoped rekey pseudonym for Refoundings (CORD-06). */ + const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt new file mode 100644 index 0000000000..d7fb4793ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHash.kt @@ -0,0 +1,97 @@ +/* + * 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.quartz.concord.crypto + +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Edition-hash chain for Control Plane editions (CORD-04 §1). + * + * Every authority edition (metadata, role, channel, grant, banlist, …) has an + * identity computed by [hash]. The next edition cites this value in its `ep` tag, + * forming an unforgeable chain: clients refuse downgrades and fold to the highest + * version with an intact chain. + * + * The preimage is fully domain-separated and every field is fixed-width or + * length-prefixed, so distinct inputs can never collide: + * + * ``` + * len64(label) ‖ label ‖ eid[32] ‖ ver_be64 ‖ hasPrev(1) ‖ prev[32] ‖ len64(content) ‖ content + * ``` + * + * where `label` is the frozen [DOMAIN] string, all `len*`/`ver` fields are + * big-endian unsigned 64-bit integers, `hasPrev` is `0x01`/`0x00`, `prev` is the + * previous edition hash (32 zero bytes for the first edition), and `content` is + * the **exact wire bytes** of the rumor content — never re-serialized. + */ +object EditionHash { + /** Frozen domain-separation label, pinned to the Concord v2 reference client. */ + const val DOMAIN = "vector-community/v1/edition" + + private val ZERO_32 = ByteArray(32) + + fun hash( + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: ByteArray, + ): ByteArray { + val label = DOMAIN.encodeToByteArray() + val prev = prevHash ?: ZERO_32 + require(entityId.size == 32) { "entityId must be 32 bytes, was ${entityId.size}" } + require(prev.size == 32) { "prevHash must be 32 bytes, was ${prev.size}" } + + // 8 + label + 32 + 8 + 1 + 32 + 8 + content + val preimage = ByteArray(8 + label.size + 32 + 8 + 1 + 32 + 8 + content.size) + var pos = 0 + pos = writeBe64(preimage, pos, label.size.toLong()) + label.copyInto(preimage, pos) + pos += label.size + entityId.copyInto(preimage, pos) + pos += 32 + pos = writeBe64(preimage, pos, version) + preimage[pos] = if (prevHash != null) 0x01 else 0x00 + pos += 1 + prev.copyInto(preimage, pos) + pos += 32 + pos = writeBe64(preimage, pos, content.size.toLong()) + content.copyInto(preimage, pos) + + return sha256(preimage) + } + + /** Convenience overload that hashes the UTF-8 bytes of a [content] string. */ + fun hash( + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + ): ByteArray = hash(entityId, version, prevHash, content.encodeToByteArray()) + + private fun writeBe64( + out: ByteArray, + offset: Int, + value: Long, + ): Int { + ConcordKeyDerivation.writeBe64(out, offset, value) + return offset + 8 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt new file mode 100644 index 0000000000..42748a661b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/GroupKey.kt @@ -0,0 +1,47 @@ +/* + * 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.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The address + keys of a Concord message plane (Control, Chat, Guestbook, …) or + * a derived channel, all produced by [ConcordKeyDerivation.groupKey]. + * + * A plane is a stream on Nostr keyed by a single shared key: + * - [secretKey] signs the stream wraps (kind 1059) at this address and derives + * the [conversationKey]. + * - [publicKey] is the 32-byte x-only pubkey that is the stream's address — + * members `REQ` for kind-1059 events authored by it. + * - [conversationKey] is the NIP-44 self-ECDH conversation key used to encrypt + * the wrap content (self-ECDH of [secretKey] against its own [publicKey]). + * + * Rotating the epoch (or the underlying secret) rotates [publicKey], keeping a + * plane's traffic unlinkable across epochs (CORD-02 §Epochs). + */ +class GroupKey( + val secretKey: ByteArray, + val publicKey: ByteArray, + val conversationKey: ByteArray, +) { + /** Lower-case hex of the x-only [publicKey] — the stream address as it appears on the wire. */ + val publicKeyHex: String get() = publicKey.toHexKey() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt new file mode 100644 index 0000000000..617cab0f58 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt @@ -0,0 +1,169 @@ +/* + * 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.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class ConcordKeyDerivationTest { + private val secretA = ByteArray(32) { 1 } + private val secretB = ByteArray(32) { 2 } + private val idA = ByteArray(32) { 0x11 } + private val idB = ByteArray(32) { 0x22 } + + // ---- buildInfo layout ----------------------------------------------------- + + @Test + fun buildInfoLayoutWithIdAndEpoch() { + val label = ConcordLabels.CHANNEL // "concord/channel" (15 bytes) + val info = ConcordKeyDerivation.buildInfo(label, idA, epoch = 1) + + // utf8(label) || 0x00 || id[32] || epoch_be8 = 15 + 1 + 32 + 8 = 56 + assertEquals(15 + 1 + 32 + 8, info.size) + assertContentEquals(label.encodeToByteArray(), info.copyOfRange(0, 15)) + assertEquals(0x00.toByte(), info[15]) + assertContentEquals(idA, info.copyOfRange(16, 48)) + // epoch 1 big-endian + assertContentEquals(byteArrayOf(0, 0, 0, 0, 0, 0, 0, 1), info.copyOfRange(48, 56)) + } + + @Test + fun buildInfoOmitsEpochWhenNull() { + val info = ConcordKeyDerivation.buildInfo(ConcordLabels.VOICE_SENDER, idA, epoch = null) + // label + 0x00 + id, no epoch tail + assertEquals(ConcordLabels.VOICE_SENDER.encodeToByteArray().size + 1 + 32, info.size) + } + + @Test + fun buildInfoOmitsIdWhenNull() { + val info = ConcordKeyDerivation.buildInfo(ConcordLabels.CONTROL, id = null, epoch = 5) + // label + 0x00 + epoch_be8 + assertEquals(ConcordLabels.CONTROL.encodeToByteArray().size + 1 + 8, info.size) + } + + // ---- groupKey ------------------------------------------------------------- + + @Test + fun groupKeyIsDeterministic() { + val a = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + val b = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + assertContentEquals(a.secretKey, b.secretKey) + assertContentEquals(a.publicKey, b.publicKey) + assertContentEquals(a.conversationKey, b.conversationKey) + } + + @Test + fun groupKeyProducesValidXOnlyPubkey() { + val gk = ConcordKeyDerivation.groupKey(ConcordLabels.CONTROL, secretA, idA, 0) + assertEquals(32, gk.publicKey.size) + assertTrue(Secp256k1Instance.isPrivateKeyValid(gk.secretKey)) + // pk must be the x-only pubkey of sk + assertContentEquals(KeyPair(privKey = gk.secretKey).pubKey, gk.publicKey) + } + + @Test + fun groupKeyRotatesWithEpoch() { + val e0 = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0) + val e1 = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 1) + assertNotEquals(e0.publicKeyHex, e1.publicKeyHex) + } + + @Test + fun groupKeyIsDistinctAcrossLabelsIdsAndSecrets() { + val base = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0).publicKeyHex + val byLabel = ConcordKeyDerivation.groupKey(ConcordLabels.CONTROL, secretA, idA, 0).publicKeyHex + val byId = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idB, 0).publicKeyHex + val bySecret = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretB, idA, 0).publicKeyHex + assertNotEquals(base, byLabel) + assertNotEquals(base, byId) + assertNotEquals(base, bySecret) + } + + @Test + fun groupKeyConversationKeyRoundTripsSelfEcdh() { + val gk = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 3) + // conversation key is self-ECDH of sk against its own pk + assertContentEquals(Nip44.v2.getConversationKey(gk.secretKey, gk.publicKey), gk.conversationKey) + + val payload = Nip44.v2.encrypt("gm chat", gk.conversationKey).encodePayload() + assertEquals("gm chat", Nip44.v2.decrypt(payload, gk.conversationKey)) + } + + // ---- communityId ---------------------------------------------------------- + + @Test + fun communityIdIsDeterministicAndOwnerBound() { + val owner = KeyPair() + val salt = ConcordKeyDerivation.newOwnerSalt() + val id1 = ConcordKeyDerivation.communityId(owner.pubKey, salt) + val id2 = ConcordKeyDerivation.communityId(owner.pubKey, salt) + assertContentEquals(id1, id2) + assertEquals(32, id1.size) + + // Different salt or different owner ⇒ different id (multiple communities per owner) + val otherSalt = ConcordKeyDerivation.newOwnerSalt() + assertNotEquals(id1.toHexKey(), ConcordKeyDerivation.communityId(owner.pubKey, otherSalt).toHexKey()) + assertNotEquals(id1.toHexKey(), ConcordKeyDerivation.communityId(KeyPair().pubKey, salt).toHexKey()) + } + + // ---- voice keys ----------------------------------------------------------- + + @Test + fun voiceKeysRideEpochAndDifferFromChatKeys() { + val chat = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secretA, idA, 0).publicKeyHex + val voiceSigner0 = ConcordKeyDerivation.voiceSignerKey(secretA, idA, 0).publicKeyHex + val voiceSigner1 = ConcordKeyDerivation.voiceSignerKey(secretA, idA, 1).publicKeyHex + assertNotEquals(chat, voiceSigner0) + assertNotEquals(voiceSigner0, voiceSigner1) + + val media = ConcordKeyDerivation.voiceMediaKey(secretA, idA, 0) + assertEquals(32, media.size) + val alice = ConcordKeyDerivation.voiceSenderKey(media, "alice") + val bob = ConcordKeyDerivation.voiceSenderKey(media, "bob") + assertEquals(32, alice.size) + assertNotEquals(alice.toHexKey(), bob.toHexKey()) + // deterministic per identity + assertContentEquals(alice, ConcordKeyDerivation.voiceSenderKey(media, "alice")) + } + + // ---- rekey locator -------------------------------------------------------- + + @Test + fun recipientLocatorIsDeterministicAndDirectionalAndEpochBound() { + val rotator = KeyPair().pubKey + val recipient = KeyPair().pubKey + val loc0 = ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 1) + assertEquals(32, loc0.size) + assertContentEquals(loc0, ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 1)) + + // direction matters (rotator‖recipient vs recipient‖rotator) + assertNotEquals(loc0.toHexKey(), ConcordKeyDerivation.recipientLocator(recipient, rotator, idA, 1).toHexKey()) + // epoch rotates the locator + assertNotEquals(loc0.toHexKey(), ConcordKeyDerivation.recipientLocator(rotator, recipient, idA, 2).toHexKey()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt new file mode 100644 index 0000000000..155ef9e2a6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/EditionHashTest.kt @@ -0,0 +1,73 @@ +/* + * 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.quartz.concord.crypto + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class EditionHashTest { + private val eid = ByteArray(32) { 0xAB.toByte() } + private val content = """{"member":"aa","role_ids":["bb"]}""" + + @Test + fun hashIsDeterministicAnd32Bytes() { + val h1 = EditionHash.hash(eid, 4, null, content) + val h2 = EditionHash.hash(eid, 4, null, content) + assertEquals(32, h1.size) + assertContentEquals(h1, h2) + } + + @Test + fun genesisAndZeroPrevAreDistinct() { + // hasPrev flag differentiates "no previous" (0x00) from an explicit zero hash (0x01) + val genesis = EditionHash.hash(eid, 0, null, content) + val zeroPrev = EditionHash.hash(eid, 0, ByteArray(32), content) + assertNotEquals(genesis.toHexKey(), zeroPrev.toHexKey()) + } + + @Test + fun versionAndContentAndEntityChangeTheHash() { + val base = EditionHash.hash(eid, 4, null, content).toHexKey() + assertNotEquals(base, EditionHash.hash(eid, 5, null, content).toHexKey()) + assertNotEquals(base, EditionHash.hash(eid, 4, null, content + " ").toHexKey()) + assertNotEquals(base, EditionHash.hash(ByteArray(32) { 0xCD.toByte() }, 4, null, content).toHexKey()) + } + + @Test + fun chainLinksThroughPrevHash() { + val v0 = EditionHash.hash(eid, 0, null, """{"name":"general"}""") + val v1 = EditionHash.hash(eid, 1, v0, """{"name":"lounge"}""") + // v1 commits to v0; recomputing v1 with a different prev breaks the link + assertNotEquals(v1.toHexKey(), EditionHash.hash(eid, 1, ByteArray(32), """{"name":"lounge"}""").toHexKey()) + } + + @Test + fun contentIsHashedAsExactBytesNotReserialized() { + // Two byte strings that differ only in whitespace must hash differently, + // proving we hash the wire bytes verbatim. + val compact = EditionHash.hash(eid, 1, null, """{"a":1}""").toHexKey() + val spaced = EditionHash.hash(eid, 1, null, """{ "a": 1 }""").toHexKey() + assertNotEquals(compact, spaced) + } +} From 30b6d70cfcc6fe90b70a486f179b89c037993468 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:41:08 +0000 Subject: [PATCH 002/115] feat(concord): add stream envelope (wrap/seal/rumor) layer Implement the Concord CORD-01 stream envelope in quartz `concord/envelope`: the inverted NIP-59 three-layer wrap -> seal -> rumor that carries every plane's traffic. The outer kind-1059/21059 wrap is signed by the shared stream key and its content is NIP-44-encrypted under the plane's self-ECDH conversation key, with an ephemeral p tag, so relays never see plaintext. - ConcordStreamEnvelope.seal: 20014 plaintext (verbatim rumor JSON, for the Control Plane) or 20013 encrypted seal, signed by the real author - wrapSeal/wrap: sign+encrypt the wrap at a GroupKey plane address - open/openOrNull: verify wrap author == stream address + wrap sig, decrypt seal, verify seal sig, decrypt/parse rumor, enforce rumor.pubkey == seal.pubkey and rumor.id == NIP-01 hash - OpenedStreamEvent: verified rumor + seal kind + author Reuses RumorAssembler, NostrSigner, NostrSignerSync and Nip44v2. Round-trip tests cover plaintext/encrypted seals, ephemeral wraps, non-member rejection (wrong epoch/secret), and confirm plaintext never leaks into wrap content. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/envelope/ConcordStreamEnvelope.kt | 186 ++++++++++++++++++ .../envelope/ConcordStreamEnvelopeTest.kt | 113 +++++++++++ 2 files changed, 299 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt new file mode 100644 index 0000000000..97dfcbfb8a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -0,0 +1,186 @@ +/* + * 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.quartz.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.crypto.verifyId +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The Concord stream envelope (CORD-01): a three-layer wrap → seal → rumor that + * carries every plane's traffic on Nostr. + * + * This is a deliberate **inversion** of NIP-59: the outer wrap is signed by the + * shared *stream key* (a plane's [GroupKey]) and carries an ephemeral `["p", …]` + * tag, rather than being signed by a random key and addressed to a fixed + * recipient. Because the true author's rumor is only ever visible after + * decrypting under the stream conversation key, a relay can never retain or + * display the plaintext as a public event. + * + * ``` + * kind 1059/21059 wrap signed by stream key, content = NIP-44(seal, streamConvKey) + * └─ kind 20013/20014 seal signed by the real author + * └─ rumor unsigned author event (kind 9, 3308, 3306, …) + * ``` + * + * Two seal flavors (CORD-01 §Encryption): + * - **Plaintext seal (20014)** — `content` is the rumor JSON verbatim. Required + * by the Control Plane so an author's signature survives re-encryption across + * epochs (the exact bytes must be preserved). + * - **Encrypted seal (20013)** — `content` is the rumor JSON NIP-44-encrypted + * under the same stream conversation key, hiding it twice over. Used by every + * plane that never crosses an epoch or re-seeds with fresh attestations. + * + * All of this is pinned to the Concord v2 reference client for wire interop. + */ +object ConcordStreamEnvelope { + const val KIND_WRAP = 1059 + const val KIND_WRAP_EPHEMERAL = 21059 + const val KIND_SEAL_ENCRYPTED = 20013 + const val KIND_SEAL_PLAINTEXT = 20014 + + /** + * Seals [rumor] for the [stream] plane, signed by [authorSigner] (the real + * author's key). [encrypted] selects a 20013 encrypted seal; otherwise a + * 20014 plaintext seal. The seal inherits the rumor's `created_at`. + */ + suspend fun seal( + rumor: Event, + stream: GroupKey, + authorSigner: NostrSigner, + encrypted: Boolean, + ): Event { + val content = + if (encrypted) { + Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload() + } else { + rumor.toJson() + } + val kind = if (encrypted) KIND_SEAL_ENCRYPTED else KIND_SEAL_PLAINTEXT + return authorSigner.sign(rumor.createdAt, kind, EMPTY_TAGS, content) + } + + /** + * Wraps an already-built [seal] into a stream event at the [stream] plane's + * address, signed by the stream key and encrypted under its conversation key. + * Adds a fresh ephemeral `["p", …]` tag. Use [KIND_WRAP_EPHEMERAL] via + * [ephemeral] for transient traffic (typing, voice presence). + */ + fun wrapSeal( + seal: Event, + stream: GroupKey, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event { + val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey)) + val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload() + val ephemeralP = KeyPair().pubKey.toHexKey() + val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP + return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) + } + + /** Convenience: [seal] then [wrapSeal] in one call. */ + suspend fun wrap( + rumor: Event, + stream: GroupKey, + authorSigner: NostrSigner, + encrypted: Boolean, + ephemeral: Boolean = false, + createdAt: Long = TimeUtils.now(), + ): Event = wrapSeal(seal(rumor, stream, authorSigner, encrypted), stream, ephemeral, createdAt) + + /** + * Opens a stream [wrap] for the [stream] plane and returns the verified author + * rumor, or throws if any layer fails to validate: + * 1. `wrap.pubkey` must equal the stream address, and the wrap must be signed + * by the stream key. + * 2. `wrap.content` decrypts under the stream conversation key into a seal + * whose own signature must verify against `seal.pubkey`. + * 3. For a 20013 seal the rumor decrypts under the same conversation key; a + * 20014 seal carries it verbatim. + * 4. The rumor's author must equal the seal's author (no impersonation) and + * its `id` must be the correct NIP-01 event hash. + */ + fun open( + wrap: Event, + stream: GroupKey, + ): OpenedStreamEvent { + require(wrap.kind == KIND_WRAP || wrap.kind == KIND_WRAP_EPHEMERAL) { + "Not a Concord stream wrap: kind ${wrap.kind}" + } + require(wrap.pubKey == stream.publicKeyHex) { + "Wrap author ${wrap.pubKey} is not the stream address ${stream.publicKeyHex}" + } + require(wrap.verify()) { "Wrap signature/id is invalid" } + + val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, stream.conversationKey)) + require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) { + "Not a Concord seal: kind ${seal.kind}" + } + require(seal.verify()) { "Seal signature/id is invalid" } + + val rumorJson = + if (seal.kind == KIND_SEAL_ENCRYPTED) { + Nip44.v2.decrypt(seal.content, stream.conversationKey) + } else { + seal.content + } + + val rumor = Event.fromJson(rumorJson) + require(rumor.pubKey == seal.pubKey) { + "Rumor author ${rumor.pubKey} does not match seal author ${seal.pubKey}" + } + require(rumor.verifyId()) { "Rumor id ${rumor.id} is not its NIP-01 hash" } + + return OpenedStreamEvent(rumor, seal.kind, seal.pubKey) + } + + /** Like [open] but returns null instead of throwing on any validation failure. */ + fun openOrNull( + wrap: Event, + stream: GroupKey, + ): OpenedStreamEvent? = + try { + open(wrap, stream) + } catch (_: Exception) { + null + } + + private val EMPTY_TAGS = emptyArray>() +} + +/** + * The verified result of opening a stream wrap: the author [rumor], the + * [sealKind] it arrived under (20013/20014), and the true [author] pubkey (equal + * to `rumor.pubKey`, surfaced for convenience). + */ +class OpenedStreamEvent( + val rumor: Event, + val sealKind: Int, + val author: String, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt new file mode 100644 index 0000000000..e9fc72f3fb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeTest.kt @@ -0,0 +1,113 @@ +/* + * 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.quartz.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordStreamEnvelopeTest { + private val authorSigner = NostrSignerInternal(KeyPair()) + private val secret = ByteArray(32) { 7 } + private val channelId = ByteArray(32) { 0x33 } + private val stream = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secret, channelId, 0) + + private fun chatRumor(text: String): Event = + RumorAssembler.assembleRumor( + pubKey = authorSigner.pubKey, + createdAt = 1_700_000_000L, + kind = 9, + tags = arrayOf(arrayOf("channel", "abc"), arrayOf("epoch", "0")), + content = text, + ) + + @Test + fun plaintextSealRoundTrips() = + runTest { + val rumor = chatRumor("hello plaintext") + val wrap = ConcordStreamEnvelope.wrap(rumor, stream, authorSigner, encrypted = false) + + // Wrap is a kind-1059 event authored by the stream address, with an ephemeral p tag. + assertEquals(ConcordStreamEnvelope.KIND_WRAP, wrap.kind) + assertEquals(stream.publicKeyHex, wrap.pubKey) + assertTrue(wrap.verify()) + val pTag = wrap.tags.first { it[0] == "p" } + assertEquals(64, pTag[1].length) + + val opened = ConcordStreamEnvelope.open(wrap, stream) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT, opened.sealKind) + assertEquals(authorSigner.pubKey, opened.author) + assertEquals(rumor.id, opened.rumor.id) + assertEquals("hello plaintext", opened.rumor.content) + assertEquals(9, opened.rumor.kind) + } + + @Test + fun encryptedSealRoundTrips() = + runTest { + val rumor = chatRumor("hello encrypted") + val wrap = ConcordStreamEnvelope.wrap(rumor, stream, authorSigner, encrypted = true) + + val opened = ConcordStreamEnvelope.open(wrap, stream) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind) + assertEquals(rumor.id, opened.rumor.id) + assertEquals("hello encrypted", opened.rumor.content) + } + + @Test + fun ephemeralWrapUsesKind21059() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("typing"), stream, authorSigner, encrypted = true, ephemeral = true) + assertEquals(ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL, wrap.kind) + assertEquals("typing", ConcordStreamEnvelope.open(wrap, stream).rumor.content) + } + + @Test + fun nonMembersCannotOpen() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("secret"), stream, authorSigner, encrypted = true) + + // A different epoch derives a different stream key ⇒ cannot open. + val otherEpoch = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, secret, channelId, 1) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, otherEpoch)) + + // A different secret (non-member) likewise cannot open. + val outsider = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 9 }, channelId, 0) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, outsider)) + } + + @Test + fun contentIsNotReadableWithoutTheStreamKey() = + runTest { + val wrap = ConcordStreamEnvelope.wrap(chatRumor("no leaks"), stream, authorSigner, encrypted = false) + // The wrap content is NIP-44 ciphertext; the plaintext must not leak into it. + assertTrue(!wrap.content.contains("no leaks")) + } +} From 828792eb409df51777a52b3b27770d1097653d3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:46:40 +0000 Subject: [PATCH 003/115] feat(concord): add kind registry, control entity kinds, permission bitfield - ConcordKinds: every Concord event kind (envelope, chat, guestbook, control, rekey, bookkeeping), pinned to Concord v2 (Armada kinds.ts) - ControlEntityKind: the kind-3308 `vsk` sub-kinds (metadata=0..dissolved=10) with wire<->enum mapping - ConcordPermissions: u64 permission bitfield with frozen bit positions, union (effective = OR of a member's roles), and decimal-string wire codec that preserves the high bits (no floating-point corruption) Tests cover frozen bit positions, union, decimal round-trip incl. bit 63, blank and garbage handling, and vsk mapping. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/ConcordPermissions.kt | 95 ++++++++++++++++++ .../concord/cord04Roles/ControlEntityKind.kt | 66 +++++++++++++ .../quartz/concord/events/ConcordKinds.kt | 66 +++++++++++++ .../cord04Roles/ConcordPermissionsTest.kt | 97 +++++++++++++++++++ 4 files changed, 324 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt new file mode 100644 index 0000000000..9edce5c798 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -0,0 +1,95 @@ +/* + * 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.quartz.concord.cord04Roles + +/** + * A member's Concord permission set (CORD-04): a `u64` bitfield of frozen bit + * positions. On the wire it is encoded as a **decimal string** (not a JSON + * number) to prevent floating-point corruption of the high bits. + * + * Bit positions are frozen forever — new permissions claim the next free bit, + * retired ones are burned and never reused. Bit 7 is retired; bits 10–12 are + * reserved. + * + * "A member's effective permissions are the union of their Roles' bits." + */ +@JvmInline +value class ConcordPermissions( + val bits: ULong, +) { + fun has(bit: Int): Boolean = (bits and (1uL shl bit)) != 0uL + + /** True if every bit set in [other] is also set here (used for role-vs-actor checks). */ + fun hasAll(other: ConcordPermissions): Boolean = (bits and other.bits) == other.bits + + infix fun union(other: ConcordPermissions): ConcordPermissions = ConcordPermissions(bits or other.bits) + + fun with(bit: Int): ConcordPermissions = ConcordPermissions(bits or (1uL shl bit)) + + fun without(bit: Int): ConcordPermissions = ConcordPermissions(bits and (1uL shl bit).inv()) + + /** The wire encoding: an unsigned decimal string with no leading zeros. */ + fun toWire(): String = bits.toString() + + companion object { + val NONE = ConcordPermissions(0uL) + + // Frozen bit positions (CORD-04 §Permission Bits). + const val MANAGE_ROLES = 0 + const val MANAGE_CHANNELS = 1 + const val MANAGE_METADATA = 2 + const val KICK = 3 + const val BAN = 4 + const val MANAGE_MESSAGES = 5 + const val CREATE_INVITE = 6 + + // bit 7 retired — never reuse + + const val VIEW_AUDIT_LOG = 8 + const val MENTION_EVERYONE = 9 + + // bits 10-12 reserved + + fun of(vararg bits: Int): ConcordPermissions { + var acc = 0uL + for (b in bits) acc = acc or (1uL shl b) + return ConcordPermissions(acc) + } + + /** + * Parses the wire form — a decimal `u64` string. Returns [NONE] for a + * blank value; throws [NumberFormatException] for a malformed or + * out-of-range one so a corrupt edition is rejected rather than folded. + */ + fun fromWire(wire: String): ConcordPermissions { + if (wire.isBlank()) return NONE + return ConcordPermissions(wire.trim().toULong()) + } + + /** Non-throwing variant: returns null on a malformed value. */ + fun fromWireOrNull(wire: String): ConcordPermissions? = + try { + fromWire(wire) + } catch (_: NumberFormatException) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt new file mode 100644 index 0000000000..f399690a1e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.concord.cord04Roles + +/** + * The Control Plane entity sub-kinds (the `vsk` tag on a kind-3308 edition), + * pinned to the Concord v2 reference client (`concord-v2/lib/kinds.ts`). + * + * On the wire the `vsk` value is a decimal string with no leading zeros + * ([wire]); [ControlEntityKind.of] maps that string back to the enum. + */ +enum class ControlEntityKind( + val wire: String, +) { + /** Community metadata (name, icon, description). */ + METADATA("0"), + + /** A Role definition (name, position, permissions, scope, color). */ + ROLE("1"), + + /** A Channel definition (name, private flag, voice flag). */ + CHANNEL("2"), + + /** A member→roles Grant. */ + GRANT("3"), + + /** The community-wide Banlist. */ + BANLIST("4"), + + /** A live invite-link registry entry (Public/Private source of truth). */ + INVITE_LIVE("6"), + + /** The aggregate invite registry. */ + INVITE_REGISTRY("8"), + + /** A revoked invite-link marker. */ + INVITE_REVOKED("9"), + + /** The dissolution tombstone (terminal). */ + DISSOLVED("10"), + ; + + companion object { + private val byWire = entries.associateBy { it.wire } + + fun of(wire: String): ControlEntityKind? = byWire[wire] + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt new file mode 100644 index 0000000000..801b755c1f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.concord.events + +/** + * Every Nostr event kind used by the Concord protocol, pinned to the Concord v2 + * reference client (Soapbox Armada, `concord-v2/lib/kinds.ts`) for wire interop. + * + * Rumor kinds (9, 1111, 3302, 3303, 3306, 3308, 3309, 3310, 3312, 3313, 23311, + * 23313, 7, 5) never appear on the wire on their own — they are always sealed + * and wrapped by [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. + * Only the wrap (1059/21059) and the bare bookkeeping kinds (33301, 13302, 13303) + * are published directly. + */ +object ConcordKinds { + // Envelope (CORD-01) + const val WRAP = 1059 + const val WRAP_EPHEMERAL = 21059 + const val SEAL_ENCRYPTED = 20013 + const val SEAL_PLAINTEXT = 20014 + + // Chat Plane rumors (CORD-03) + const val MESSAGE = 9 + const val COMMENT = 1111 + const val REACTION = 7 + const val DELETE = 5 + const val EDIT = 3302 + const val WEBXDC = 3310 + const val TYPING = 23311 + const val VOICE_PRESENCE = 23313 + + // Guestbook Plane rumors (CORD-02) + const val JOIN_LEAVE = 3306 + const val KICK = 3309 + const val SNAPSHOT = 3312 + + // Person-addressed rumor (CORD-05) + const val DIRECT_INVITE = 3313 + + // Control / rekey rumors (CORD-02/04/06) + const val CONTROL = 3308 + const val REKEY = 3303 + + // Bare bookkeeping events + const val INVITE_BUNDLE = 33301 // addressable, CORD-05 + const val COMMUNITY_LIST = 13302 // private self-list, CORD-05 + const val INVITE_LIST = 13303 // private self-list, CORD-05 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt new file mode 100644 index 0000000000..dbe9b16a18 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissionsTest.kt @@ -0,0 +1,97 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MANAGE_ROLES +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.MENTION_EVERYONE +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordPermissionsTest { + @Test + fun bitPositionsAreFrozen() { + assertEquals(0, MANAGE_ROLES) + assertEquals(3, KICK) + assertEquals(4, BAN) + assertEquals(9, MENTION_EVERYONE) + } + + @Test + fun hasChecksIndividualBits() { + val p = ConcordPermissions.of(KICK, BAN) + assertTrue(p.has(KICK)) + assertTrue(p.has(BAN)) + assertFalse(p.has(MANAGE_ROLES)) + } + + @Test + fun unionIsBitwiseOr() { + val a = ConcordPermissions.of(KICK) + val b = ConcordPermissions.of(BAN) + val u = a union b + assertTrue(u.has(KICK)) + assertTrue(u.has(BAN)) + // effective permissions are the union of a member's roles' bits + assertEquals(ConcordPermissions.of(KICK, BAN).bits, u.bits) + } + + @Test + fun wireEncodingIsDecimalString() { + // KICK(3) | BAN(4) = 0b11000 = 24 + assertEquals("24", ConcordPermissions.of(KICK, BAN).toWire()) + assertEquals(ConcordPermissions.of(KICK, BAN).bits, ConcordPermissions.fromWire("24").bits) + } + + @Test + fun highBitsSurviveDecimalRoundTripWithoutFloatingPointLoss() { + // Bit 63 set — a value that would be corrupted if parsed as a JSON double. + val hi = ConcordPermissions(1uL shl 63) + val wire = hi.toWire() + assertEquals("9223372036854775808", wire) + assertEquals(hi.bits, ConcordPermissions.fromWire(wire).bits) + } + + @Test + fun blankParsesToNoneAndGarbageIsRejected() { + assertEquals(ConcordPermissions.NONE.bits, ConcordPermissions.fromWire("").bits) + assertNull(ConcordPermissions.fromWireOrNull("not-a-number")) + assertNull(ConcordPermissions.fromWireOrNull("-1")) + } + + @Test + fun entityKindWireMappingMatchesReference() { + assertEquals(ControlEntityKind.METADATA, ControlEntityKind.of("0")) + assertEquals(ControlEntityKind.ROLE, ControlEntityKind.of("1")) + assertEquals(ControlEntityKind.CHANNEL, ControlEntityKind.of("2")) + assertEquals(ControlEntityKind.GRANT, ControlEntityKind.of("3")) + assertEquals(ControlEntityKind.BANLIST, ControlEntityKind.of("4")) + assertEquals(ControlEntityKind.INVITE_LIVE, ControlEntityKind.of("6")) + assertEquals(ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.of("8")) + assertEquals(ControlEntityKind.INVITE_REVOKED, ControlEntityKind.of("9")) + assertEquals(ControlEntityKind.DISSOLVED, ControlEntityKind.of("10")) + assertNull(ControlEntityKind.of("7")) // retired/unknown + } +} From c865d0f4ebf39afdb42c5f56154a241b0f38e2e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:51:35 +0000 Subject: [PATCH 004/115] feat(concord): add control edition parsing and chain folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ControlEdition: parses a verified kind-3308 rumor's vsk/eid/ev/ep/vac tags into a typed edition, computes its domain-separated edition hash, and rejects malformed editions (bad kind, missing eid/ev, unknown vsk, bad hex) - AuthorityCitation: the vac Grant pin an actor claims rank under - EditionFold: folds editions into each entity's current head — genesis anchoring, intact-chain / no-downgrade advancement, deterministic lower-rumor-id tie-break for convergence (authority-weighted tie-break layered in the resolver) Tests cover tag parsing + hash, malformed rejection, genesis prev handling, order-independent chain walk, downgrade/broken-chain refusal, tie-break, and the hold-without-genesis case. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/ControlEdition.kt | 124 ++++++++++++++ .../quartz/concord/cord04Roles/EditionFold.kt | 81 +++++++++ .../concord/cord04Roles/ControlEditionTest.kt | 156 ++++++++++++++++++ 3 files changed, 361 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt new file mode 100644 index 0000000000..5e36890830 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt @@ -0,0 +1,124 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.crypto.EditionHash +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The exact Grant edition an actor claims authority under (the `vac` tag, + * CORD-04). Verifiers block until they have synced this Grant, then resolve the + * actor's rank against it — a demoted member's stale citation is dropped. + */ +class AuthorityCitation( + val grantId: ByteArray, + val grantVersion: Long, + val grantHash: ByteArray, +) + +/** + * A single Control Plane edition (a kind-3308 author rumor, CORD-02/04). + * + * Editions are versioned, chainable state: each carries an entity id ([entityId], + * `eid`), a monotonically increasing [version] (`ev`), the [prevHash] of the + * previous edition (`ep`, absent for the genesis edition), an optional + * [authorityCitation] (`vac`) pinning the Grant the [author] acts under, and the + * verbatim entity [content]. Its identity is [hash] — a domain-separated hash of + * exactly those fields (see [EditionHash]) — which the next edition cites in `ep`, + * forming an unforgeable chain. + */ +class ControlEdition( + val entityKind: ControlEntityKind, + val entityId: ByteArray, + val version: Long, + val prevHash: ByteArray?, + val authorityCitation: AuthorityCitation?, + val content: String, + /** The actor's real pubkey (the seal/rumor author). */ + val author: String, + /** The rumor's event id — the deterministic tie-break key at equal version. */ + val rumorId: String, + val createdAt: Long, +) { + /** Domain-separated edition identity; the next edition's `ep` cites this. */ + val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) } + + val entityIdHex: String get() = entityId.toHexKey() + val hashHex: String get() = hash.toHexKey() + + companion object { + const val TAG_VSK = "vsk" + const val TAG_EID = "eid" + const val TAG_EV = "ev" + const val TAG_EP = "ep" + const val TAG_VAC = "vac" + + /** + * Parses a decrypted, verified kind-3308 [rumor] (its [author] is the + * rumor's pubkey) into a [ControlEdition], or returns null if it is not a + * well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`, + * malformed hex, …) so the caller drops it rather than folding garbage. + */ + fun fromRumor( + rumor: Event, + author: String = rumor.pubKey, + ): ControlEdition? { + if (rumor.kind != ConcordKinds.CONTROL) return null + val entityKind = rumor.tags.firstTagValue(TAG_VSK)?.let { ControlEntityKind.of(it) } ?: return null + val entityId = + rumor.tags + .firstTagValue(TAG_EID) + ?.hexToByteArrayOrNull() + ?.takeIf { it.size == 32 } ?: return null + val version = rumor.tags.firstTagValueAsLong(TAG_EV) ?: return null + if (version < 0) return null + + val prevValue = rumor.tags.firstTagValue(TAG_EP) + val prevHash = if (prevValue.isNullOrBlank()) null else prevValue.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + + val vacTag = rumor.tags.firstOrNull { it.size >= 4 && it[0] == TAG_VAC } + val vac = + vacTag?.let { + val gid = it[1].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null + val gver = it[2].toLongOrNull() ?: return null + val ghash = it[3].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null + AuthorityCitation(gid, gver, ghash) + } + + return ControlEdition( + entityKind = entityKind, + entityId = entityId, + version = version, + prevHash = prevHash, + authorityCitation = vac, + content = rumor.content, + author = author, + rumorId = rumor.id, + createdAt = rumor.createdAt, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt new file mode 100644 index 0000000000..99602208e1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -0,0 +1,81 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Folds Control Plane editions into the current head of each entity (CORD-04 + * §Edition Hashing & Chain Integrity). + * + * Rules enforced here: + * - **Genesis anchoring** — a chain starts at the lowest-version edition with no + * `ep` (prev hash). Without a genesis, the entity has no accepted head (we + * hold rather than trust an unrooted later edition). + * - **Intact chain / no downgrades** — the head advances to `version + 1` only + * when that edition's `ep` cites the current head's [ControlEdition.hash]. + * Lower or non-chaining versions are ignored. + * - **Deterministic convergence** — at equal version, ties break on the lower + * rumor id, so every honest client folds to the same head. + * + * Authority-weighted tie-break ("authority first, then the lower rumor id") and + * the owner-rooted `vac` verification are applied by the resolver layer on top of + * this structural fold; this class is purely the chain walk. + */ +object EditionFold { + /** Groups mixed [editions] by entity id and folds each to its head. */ + fun fold(editions: Collection): Map { + val byEntity = editions.groupBy { it.entityIdHex } + val out = HashMap(byEntity.size) + for ((entity, list) in byEntity) { + foldEntity(list)?.let { out[entity] = it } + } + return out + } + + /** Folds the editions of a single entity into its current head, or null. */ + fun foldEntity(editions: List): ControlEdition? { + if (editions.isEmpty()) return null + + // Index editions by version, keeping the tie-break winner where several + // share a version (lower rumor id wins). + val byVersion = HashMap>() + for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) + + // Genesis: lowest version with no prev hash. Prefer the tie-break winner. + var head = + editions + .filter { it.prevHash == null } + .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: return null + + // Walk the chain upward while the next version chains from the current head. + while (true) { + val next = + byVersion[head.version + 1] + ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } + ?.minByOrNull { it.rumorId } + ?: break + head = next + } + return head + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt new file mode 100644 index 0000000000..6d3e836b13 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -0,0 +1,156 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.crypto.EditionHash +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ControlEditionTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + rumorId: String, + ) = ControlEdition( + entityKind = ControlEntityKind.CHANNEL, + entityId = eid, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + // ---- fromRumor ------------------------------------------------------------ + + @Test + fun fromRumorParsesTagsAndComputesHash() { + val prev = ByteArray(32) { 0x01 } + val grantId = ByteArray(32) { 0x02 } + val grantHash = ByteArray(32) { 0x03 } + val content = """{"member":"aa","role_ids":["bb"]}""" + val tags = + arrayOf( + arrayOf("vsk", "3"), + arrayOf("eid", eid.toHexKey()), + arrayOf("ev", "4"), + arrayOf("ep", prev.toHexKey()), + arrayOf("vac", grantId.toHexKey(), "2", grantHash.toHexKey()), + ) + val rumor = RumorAssembler.assembleRumor(author, 1_700_000_000L, ConcordKinds.CONTROL, tags, content) + + val ed = ControlEdition.fromRumor(rumor) + assertNotNull(ed) + assertEquals(ControlEntityKind.GRANT, ed.entityKind) + assertContentEquals(eid, ed.entityId) + assertEquals(4, ed.version) + assertContentEquals(prev, ed.prevHash) + assertEquals(author, ed.author) + assertEquals(rumor.id, ed.rumorId) + assertContentEquals(EditionHash.hash(eid, 4, prev, content), ed.hash) + assertNotNull(ed.authorityCitation) + assertContentEquals(grantId, ed.authorityCitation.grantId) + assertEquals(2, ed.authorityCitation.grantVersion) + } + + @Test + fun fromRumorRejectsMalformed() { + // wrong kind + assertNull(ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, 9, arrayOf(arrayOf("vsk", "0")), "{}"))) + // missing eid + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor(author, 1L, ConcordKinds.CONTROL, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"), + ), + ) + // unknown vsk (bit 7 retired) + assertNull( + ControlEdition.fromRumor( + RumorAssembler.assembleRumor( + author, + 1L, + ConcordKinds.CONTROL, + arrayOf(arrayOf("vsk", "7"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), + "{}", + ), + ), + ) + } + + @Test + fun genesisHasNullPrevWhenEpAbsent() { + val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")) + val ed = ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, ConcordKinds.CONTROL, tags, """{"name":"general"}""")) + assertNotNull(ed) + assertNull(ed.prevHash) + } + + // ---- fold ----------------------------------------------------------------- + + @Test + fun foldWalksIntactChainToHead() { + val v0 = edition(0, null, """{"name":"general"}""", "id0") + val v1 = edition(1, v0.hash, """{"name":"lounge"}""", "id1") + val v2 = edition(2, v1.hash, """{"name":"lobby"}""", "id2") + // order shuffled to prove fold is order-independent + val head = EditionFold.foldEntity(listOf(v2, v0, v1)) + assertEquals(v2.rumorId, head?.rumorId) + } + + @Test + fun foldStopsAtBreakAndRefusesDowngrade() { + val v0 = edition(0, null, """{"name":"general"}""", "id0") + // v2 present but v1 missing ⇒ head cannot advance past v0 + val v2 = edition(2, ByteArray(32) { 0x09 }, """{"name":"lobby"}""", "id2") + assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, v2))?.rumorId) + + // v1 with a prev that does not chain from v0 is ignored + val badV1 = edition(1, ByteArray(32) { 0x07 }, """{"name":"x"}""", "id1") + assertEquals(v0.rumorId, EditionFold.foldEntity(listOf(v0, badV1))?.rumorId) + } + + @Test + fun foldTieBreaksOnLowerRumorId() { + val a = edition(0, null, """{"name":"a"}""", "aaa") + val b = edition(0, null, """{"name":"b"}""", "bbb") + assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId) + } + + @Test + fun foldWithoutGenesisReturnsNull() { + val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1") + assertNull(EditionFold.foldEntity(listOf(v1))) + } +} From 71db306f8da99156beea7cee29f07cf814b9dae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:09 +0000 Subject: [PATCH 005/115] feat(concord): add owner-rooted authority resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement CORD-04 authority resolution over a folded Control Plane: - ControlEntities: Role and Grant content DTOs (kotlinx.serialization, lenient + extensible) and a Banlist array parser; ConcordJson facility - AuthorityResolver: builds roster state from the entity heads + known owner and answers rank (lower = higher; owner = 0), effectivePermissions (union of a member's roles), isBanned, hasPermission, and canActOn (holds the bit AND strictly outranks the target — equal cannot act on equal; owner unremovable) Grants are validated by an owner-rooted fixpoint: a Grant is honored only when its signer already outranks every assigned Role and holds MANAGE_ROLES, so the roster grows strictly outward from the owner and self-referential cycles never bootstrap. Deleted/position-0 roles and banned members are dropped. Banlists heal to their union. Tests cover owner-rooted ranks/permissions, fixpoint order-independence, unauthorized/insufficient-rank grant rejection, ban vanishing, and invalid-role dropping. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/AuthorityResolver.kt | 174 ++++++++++++++++++ .../concord/cord04Roles/ConcordPermissions.kt | 3 + .../concord/cord04Roles/ControlEntities.kt | 86 +++++++++ .../cord04Roles/AuthorityResolverTest.kt | 166 +++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt new file mode 100644 index 0000000000..c5084d47cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -0,0 +1,174 @@ +/* + * 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.quartz.concord.cord04Roles + +/** + * Resolves the owner-rooted authority state of a Concord community from its + * folded Control Plane (CORD-04). + * + * "The Roster is owner-rooted: every Grant and Role is signed by an npub the + * Roster ranks strictly above it, and the chain terminates at the owner." + * + * Build one with [resolve] from the current entity heads (the values of + * [EditionFold.fold]) plus the community's known owner pubkey. It then answers: + * - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a + * member with no validly-granted role has no rank). + * - [effectivePermissions] — the union of a member's roles' bits (owner: all). + * - [isBanned] — membership in the healed banlist union. + * - [canActOn] — whether an actor may take a permissioned action on a target: + * the actor must hold the bit, must strictly outrank the target (equal cannot + * act on equal), and the owner is unremovable. + * + * Grants are validated by a fixpoint that only ever empowers members reachable + * from the owner: a Grant is honored when its signer already outranks every + * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never + * touch the owner can never bootstrap themselves. + */ +class AuthorityResolver private constructor( + private val ownerLower: String, + private val roles: Map, + private val memberRoles: Map>, + private val banned: Set, +) { + fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower + + fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned + + /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ + fun rank(pubKey: String): Long? { + val m = pubKey.lowercase() + if (m == ownerLower) return OWNER_RANK + val held = memberRoles[m] ?: return null + return held.mapNotNull { roles[it]?.position }.minOrNull() + } + + /** The union of a member's roles' permission bits (owner holds every bit). */ + fun effectivePermissions(pubKey: String): ConcordPermissions { + val m = pubKey.lowercase() + if (m == ownerLower) return ConcordPermissions.ALL + val held = memberRoles[m] ?: return ConcordPermissions.NONE + var acc = ConcordPermissions.NONE + for (id in held) roles[id]?.let { acc = acc union it.permissionBits() } + return acc + } + + /** True if the member holds [bit] and is not banned. */ + fun hasPermission( + pubKey: String, + bit: Int, + ): Boolean = !isBanned(pubKey) && effectivePermissions(pubKey).has(bit) + + /** + * Whether [actor] may take the action guarded by permission [bit] against + * [target]. Requires: actor not banned, actor holds [bit], the owner is never + * a valid target (unremovable), and actor strictly outranks target — "equal + * cannot act on equal". + */ + fun canActOn( + actor: String, + target: String, + bit: Int, + ): Boolean { + if (!hasPermission(actor, bit)) return false + if (isOwner(target)) return false + val actorRank = rank(actor) ?: return false + val targetRank = rank(target) ?: Long.MAX_VALUE // no roles ⇒ lowest authority + return actorRank < targetRank + } + + companion object { + /** The owner's rank — supreme and unremovable. No Role may claim it. */ + const val OWNER_RANK = 0L + + fun resolve( + heads: Collection, + ownerPubKey: String, + ): AuthorityResolver { + val ownerLower = ownerPubKey.lowercase() + + // Roles: keyed by role id (the edition entity id). Drop deleted roles + // and any that illegally claim position 0 (owner-peer). + val roles = HashMap() + for (e in heads) { + if (e.entityKind != ControlEntityKind.ROLE) continue + val r = ConcordJson.decodeOrNull(e.content) ?: continue + if (r.deleted || r.position < 1) continue + roles[e.entityIdHex] = r + } + + // Grants: one head per member; remember the signing actor (granter). + val grants = + heads.mapNotNull { e -> + if (e.entityKind != ControlEntityKind.GRANT) return@mapNotNull null + val g = ConcordJson.decodeOrNull(e.content) ?: return@mapNotNull null + ParsedGrant(g.member.lowercase(), g.roleIds, e.author.lowercase()) + } + + // Banlist: heal to the union of every banlist head. + val banned = HashSet() + for (e in heads) { + if (e.entityKind != ControlEntityKind.BANLIST) continue + ConcordJson.decodeBanlist(e.content)?.forEach { banned.add(it.lowercase()) } + } + + val memberRoles = HashMap>() + + fun rankOf(member: String): Long? { + if (member == ownerLower) return OWNER_RANK + val held = memberRoles[member] ?: return null + return held.mapNotNull { roles[it]?.position }.minOrNull() + } + + fun holdsManageRoles(member: String): Boolean { + if (member == ownerLower) return true + val held = memberRoles[member] ?: return false + return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true } + } + + // Fixpoint: apply grants whose signer outranks every assigned role and + // holds MANAGE_ROLES. Only the owner is empowered a priori, so the + // roster grows strictly outward from the owner. + var changed = true + while (changed) { + changed = false + for (g in grants) { + val granterRank = rankOf(g.granter) ?: continue + if (!holdsManageRoles(g.granter)) continue + val assigned = g.roleIds.filter { roles.containsKey(it) } + if (assigned.any { granterRank >= roles.getValue(it).position }) continue // must strictly outrank each + val newSet = assigned.toSet() + if (memberRoles[g.member] != newSet) { + memberRoles[g.member] = newSet + changed = true + } + } + } + + return AuthorityResolver(ownerLower, roles, memberRoles, banned) + } + } + + private class ParsedGrant( + val member: String, + val roleIds: List, + val granter: String, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt index 9edce5c798..8250599d78 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -52,6 +52,9 @@ value class ConcordPermissions( companion object { val NONE = ConcordPermissions(0uL) + /** Every bit set — the owner's implicit, supreme permission set. */ + val ALL = ConcordPermissions(ULong.MAX_VALUE) + // Frozen bit positions (CORD-04 §Permission Bits). const val MANAGE_ROLES = 0 const val MANAGE_CHANNELS = 1 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt new file mode 100644 index 0000000000..6e8c02a583 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -0,0 +1,86 @@ +/* + * 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.quartz.concord.cord04Roles + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * JSON facility for Concord Control Plane entity content. Unknown keys are + * ignored because entity shapes are deliberately client-extensible (CORD-03/04), + * and defaults are lenient so a partial entity never throws mid-fold. + */ +object ConcordJson { + val instance = + Json { + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + } + + inline fun decodeOrNull(content: String): T? = + try { + instance.decodeFromString(content) + } catch (_: Exception) { + null + } + + /** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */ + fun decodeBanlist(content: String): List? = + try { + instance.decodeFromString(ListSerializer(String.serializer()), content) + } catch (_: Exception) { + null + } +} + +/** + * A Role's content (CORD-04): a named bundle of permissions at a [position]. + * The role's id is the edition's entity id, not a content field. Lower [position] + * ranks higher; no role may claim position 0 (reserved for the owner). + */ +@Serializable +class RoleEntity( + val name: String = "", + val position: Long = 0, + /** u64 permission bitfield as a decimal string. */ + val permissions: String = "0", + /** "server"-wide, or a channel id for a channel-scoped role. Null = server. */ + val scope: String? = null, + val color: Long = 0, + val deleted: Boolean = false, +) { + fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE +} + +/** + * A Grant's content (CORD-04): maps a [member] to the set of [roleIds] they hold. + * Honored only if the granting actor outranks every assigned Role and the chain + * terminates at the owner (see [AuthorityResolver]). + */ +@Serializable +class GrantEntity( + val member: String = "", + @SerialName("role_ids") val roleIds: List = emptyList(), +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt new file mode 100644 index 0000000000..0d21a74dd0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -0,0 +1,166 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.KICK +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class AuthorityResolverTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val carol = "c3".repeat(32) + private val dave = "d4".repeat(32) + + private val adminRole = "11".repeat(32) + private val modRole = "22".repeat(32) + + // admin: position 1, KICK|BAN|MANAGE_ROLES = 8|16|1 = 25 + private val adminJson = """{"name":"Admin","position":1,"permissions":"25"}""" + + // mod: position 5, KICK only = 8 (no MANAGE_ROLES) + private val modJson = """{"name":"Mod","position":5,"permissions":"8"}""" + + private fun role( + roleId: String, + json: String, + ) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0) + + private fun grant( + grantId: String, + member: String, + roleIds: List, + granter: String, + ) = ControlEdition( + ControlEntityKind.GRANT, + grantId.hexToByteArray(), + 0, + null, + null, + """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", + granter, + "grant-$grantId", + 0, + ) + + private fun banlist(vararg banned: String) = + ControlEdition( + ControlEntityKind.BANLIST, + "44".repeat(32).hexToByteArray(), + 0, + null, + null, + "[${banned.joinToString(",") { "\"$it\"" }}]", + owner, + "ban", + 0, + ) + + @Test + fun ranksPermissionsAndActionAuthorityAreOwnerRooted() { + val heads = + listOf( + role(adminRole, adminJson), + role(modRole, modJson), + // alice's grant appears BEFORE the owner's grant to prove fixpoint order-independence + grant("ba".repeat(32), bob, listOf(modRole), granter = alice), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + + assertTrue(r.isOwner(owner)) + assertEquals(0L, r.rank(owner)) + assertEquals(1L, r.rank(alice)) + assertEquals(5L, r.rank(bob)) + assertNull(r.rank(carol)) // no grant ⇒ no authority + + assertTrue(r.effectivePermissions(alice).has(BAN)) + assertFalse(r.effectivePermissions(bob).has(BAN)) + assertTrue(r.effectivePermissions(bob).has(KICK)) + + // Higher rank can act on lower; equal cannot act on equal; nobody on owner. + assertTrue(r.canActOn(alice, bob, BAN)) + assertFalse(r.canActOn(bob, alice, KICK)) // lower cannot act on higher + assertFalse(r.canActOn(alice, alice, KICK)) // equal-on-equal + assertFalse(r.canActOn(alice, owner, BAN)) // owner unremovable + assertTrue(r.canActOn(owner, alice, BAN)) // owner supreme + } + + @Test + fun grantsFromUnauthorizedSignersAreIgnored() { + val heads = + listOf( + role(adminRole, adminJson), + // carol has no authority, so her grant to dave is dropped + grant("cd".repeat(32), dave, listOf(adminRole), granter = carol), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertNull(r.rank(dave)) + assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits) + } + + @Test + fun granterMustHoldManageRolesAndOutrankAssignedRole() { + val heads = + listOf( + role(adminRole, adminJson), + role(modRole, modJson), + grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES) + grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin + ) + val r = AuthorityResolver.resolve(heads, owner) + assertEquals(5L, r.rank(alice)) + assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin + } + + @Test + fun bannedMembersVanishFromAuthority() { + val heads = + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + banlist(alice), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertTrue(r.isBanned(alice)) + // A banned actor can take no action even though the role bit is present. + assertFalse(r.hasPermission(alice, BAN)) + assertFalse(r.canActOn(alice, bob, BAN)) + } + + @Test + fun deletedRolesAndPositionZeroAreDropped() { + val heads = + listOf( + role(adminRole, """{"name":"Admin","position":1,"permissions":"25","deleted":true}"""), + role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0 + grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertNull(r.rank(alice)) // both assigned roles are invalid + } +} From d51d8e6d71a3baf8374b19c8f8ffc3c95a866ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:59:05 +0000 Subject: [PATCH 006/115] feat(concord): add channel key derivation and chat message binding CORD-03 Chat Plane vertical slice tying crypto + envelope together: - ConcordChannelKeys: public (community_root) and private (channel_key) channel key derivation, both via group_key with channel_id folded in so each channel has a distinct, epoch-rotating address - ChannelChat: channel/epoch binding tags, a kind-9 message rumor builder, and isBoundTo validation so an event can't be replayed across channels/epochs End-to-end test proves two members holding the same community_root independently derive the identical public channel plane and one reads the other's message with no key distribution, non-members can't derive the plane, cross-channel/epoch replay is rejected, and epoch rotation rotates the address. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord03Channels/ChannelChat.kt | 86 ++++++++++++++ .../cord03Channels/ConcordChannelKeys.kt | 56 +++++++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 110 ++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt new file mode 100644 index 0000000000..2aa2eec960 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -0,0 +1,86 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * Chat Plane message binding (CORD-03). + * + * Every Chat Plane rumor — a message, reply, reaction, edit, or delete — commits + * to the channel and epoch it belongs to via `["channel", ]` and + * `["epoch", ]` tags inside the author-signed rumor. Recipients enforce this + * binding ([isBoundTo]) so an event lifted from one channel/epoch can't be + * replayed into another. + */ +object ChannelChat { + const val TAG_CHANNEL = "channel" + const val TAG_EPOCH = "epoch" + + /** Builds the channel/epoch binding tags shared by every Chat Plane rumor. */ + fun bindingTags( + channelId: HexKey, + epoch: Long, + ): Array> = arrayOf(arrayOf(TAG_CHANNEL, channelId), arrayOf(TAG_EPOCH, epoch.toString())) + + /** + * Builds an unsigned kind-9 chat message rumor bound to [channelId]/[epoch]. + * Wrap it for the channel plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] (encrypted + * seal) to publish. + */ + fun message( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + createdAt: Long, + extraTags: Array> = emptyArray(), + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = ConcordKinds.MESSAGE, + tags = bindingTags(channelId, epoch) + extraTags, + content = text, + ) + + /** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */ + fun channelOf(rumor: Event): HexKey? = rumor.tags.firstTagValue(TAG_CHANNEL) + + /** The epoch a Chat Plane [rumor] is bound to, or null if unbound/malformed. */ + fun epochOf(rumor: Event): Long? = rumor.tags.firstTagValue(TAG_EPOCH)?.toLongOrNull() + + /** + * True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients + * must reject any Chat Plane event whose binding does not match the plane it + * arrived on. + */ + fun isBoundTo( + rumor: Event, + channelId: HexKey, + epoch: Long, + ): Boolean = channelOf(rumor) == channelId && epochOf(rumor) == epoch +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt new file mode 100644 index 0000000000..65dd178f9c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelKeys.kt @@ -0,0 +1,56 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +import com.vitorpamplona.quartz.concord.crypto.GroupKey + +/** + * Channel Chat Plane key derivation (CORD-03). + * + * Both channel types use the same `group_key("concord/channel", secret, + * channel_id, epoch)` derivation; only the secret and epoch differ: + * - **Public** channels derive from the shared `community_root` at the current + * root epoch — every member can derive them, so no key is distributed. + * - **Private** channels derive from their own random `channel_key` at the + * channel's own epoch — the key is delivered on role grant and rotated on + * revocation. + * + * The `channel_id` is folded into the derivation so every channel gets a distinct + * address regardless of the secret source, and it stays constant across + * visibility conversions and epoch rotations. + */ +object ConcordChannelKeys { + /** Public channel: derived from the community root at the root epoch. */ + fun publicChannel( + communityRoot: ByteArray, + channelId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, communityRoot, channelId, rootEpoch) + + /** Private channel: derived from its own channel key at the channel epoch. */ + fun privateChannel( + channelKey: ByteArray, + channelId: ByteArray, + channelEpoch: Long, + ): GroupKey = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, channelKey, channelId, channelEpoch) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt new file mode 100644 index 0000000000..ac202549c6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -0,0 +1,110 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Full CORD-01+03 vertical slice: two members holding the same community_root + * independently derive a public channel key, and one reads the other's message + * off the shared plane — no key distribution required. + */ +class ChannelChatEndToEndTest { + private val communityRoot = ByteArray(32) { 0x5A } + private val channelId = ByteArray(32) { 0x42 } + private val channelIdHex = channelId.toHexKey() + private val rootEpoch = 0L + + @Test + fun twoMembersShareAPublicChannelWithoutKeyDistribution() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + + // Alice derives the public channel plane and sends a message. + val aliceChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val rumor = ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "gm #general", createdAt = 1_700_000_000L) + val wrap = ConcordStreamEnvelope.wrap(rumor, aliceChannel, alice, encrypted = true) + + // Bob, holding the same community_root, derives the identical plane and reads it. + val bobChannel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + assertEquals(aliceChannel.publicKeyHex, bobChannel.publicKeyHex) + + val opened = ConcordStreamEnvelope.open(wrap, bobChannel) + assertEquals("gm #general", opened.rumor.content) + assertEquals(alice.pubKey, opened.author) + assertTrue(ChannelChat.isBoundTo(opened.rumor, channelIdHex, rootEpoch)) + } + + @Test + fun bindingRejectsCrossChannelAndCrossEpochReplay() { + val rumor = + ChannelChat.message( + authorPubKey = KeyPair().pubKey.toHexKey(), + channelId = channelIdHex, + epoch = 0L, + text = "hi", + createdAt = 1L, + ) + assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0L)) + assertFalse(ChannelChat.isBoundTo(rumor, channelIdHex, 1L)) // wrong epoch + assertFalse(ChannelChat.isBoundTo(rumor, "00".repeat(32), 0L)) // wrong channel + assertEquals(channelIdHex, ChannelChat.channelOf(rumor)) + assertEquals(0L, ChannelChat.epochOf(rumor)) + } + + @Test + fun nonMembersCannotDeriveThePlane() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + val wrap = + ConcordStreamEnvelope.wrap( + ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "secret", 1L), + channel, + alice, + encrypted = true, + ) + + // A different community_root derives a different plane key ⇒ cannot open. + val outsiderPlane = ConcordChannelKeys.publicChannel(ByteArray(32) { 0x01 }, channelId, rootEpoch) + assertNull(ConcordStreamEnvelope.openOrNull(wrap, outsiderPlane)) + } + + @Test + fun epochRotationRotatesTheChannelAddress() { + val e0 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val e1 = ConcordChannelKeys.publicChannel(communityRoot, channelId, 1) + assertFalse(e0.publicKeyHex == e1.publicKeyHex) + + // A private channel with its own key is distinct from the public one at the same id. + val priv = ConcordChannelKeys.privateChannel(ByteArray(32) { 0x77 }, channelId, 0) + assertFalse(priv.publicKeyHex == e0.publicKeyHex) + } +} From 982cbb6a21b888c8dffecf12bfed1b210ac54ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:02:54 +0000 Subject: [PATCH 007/115] feat(concord): add community-state fold and guestbook plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelEntity/MetadataEntity content DTOs (CORD-02/03) - ConcordCommunityState.fold: folds control editions + known owner into the live community view — metadata, non-deleted channels, live roles, the owner-rooted AuthorityResolver, and a dissolved flag from the tombstone - Guestbook: self-signed join/leave (kind 3306, with invite attribution) and authorized kick (kind 3309) rumor builders + parsers; off-consensus membership motion Tests cover metadata/channel/role folding with deleted-channel exclusion, authority wiring, the dissolution tombstone, and join/leave/kick round-trips. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../cord02Community/ConcordCommunityState.kt | 98 ++++++++++++++ .../concord/cord02Community/Guestbook.kt | 126 ++++++++++++++++++ .../concord/cord04Roles/ControlEntities.kt | 21 +++ .../ConcordCommunityStateTest.kt | 83 ++++++++++++ .../concord/cord02Community/GuestbookTest.kt | 66 +++++++++ 5 files changed, 394 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt new file mode 100644 index 0000000000..daba4d65ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -0,0 +1,98 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity + +/** A channel id paired with its current folded definition. */ +class ConcordChannel( + val channelIdHex: String, + val definition: ChannelEntity, +) + +/** + * The current, folded state of a Concord community's Control Plane (CORD-02). + * + * Produced by [fold] from the set of decrypted, verified control editions plus + * the community's known [ownerPubKey]. It exposes the community [metadata], the + * live (non-deleted) [channels], the live [roles], the owner-rooted [authority] + * resolver, and whether the community has been [dissolved]. + * + * "Every member keeps the entire Control Plane in sync — it is small and must + * stay complete." Recompute this whenever the known editions change. + */ +class ConcordCommunityState( + val ownerPubKey: String, + val metadata: MetadataEntity?, + val channels: Map, + val roles: Map, + val authority: AuthorityResolver, + val dissolved: Boolean, +) { + companion object { + fun fold( + editions: Collection, + ownerPubKey: String, + ): ConcordCommunityState { + val heads = EditionFold.fold(editions).values + + val metadata = + heads + .filter { it.entityKind == ControlEntityKind.METADATA } + .maxByOrNull { it.version } // one metadata entity; guard against strays + ?.let { ConcordJson.decodeOrNull(it.content) } + + val channels = LinkedHashMap() + for (e in heads) { + if (e.entityKind != ControlEntityKind.CHANNEL) continue + val def = ConcordJson.decodeOrNull(e.content) ?: continue + if (def.deleted) continue + channels[e.entityIdHex] = ConcordChannel(e.entityIdHex, def) + } + + val roles = HashMap() + for (e in heads) { + if (e.entityKind != ControlEntityKind.ROLE) continue + val r = ConcordJson.decodeOrNull(e.content) ?: continue + if (r.deleted) continue + roles[e.entityIdHex] = r + } + + val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED } + + return ConcordCommunityState( + ownerPubKey = ownerPubKey.lowercase(), + metadata = metadata, + channels = channels, + roles = roles, + authority = AuthorityResolver.resolve(heads, ownerPubKey), + dissolved = dissolved, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt new file mode 100644 index 0000000000..37ce1dfd35 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt @@ -0,0 +1,126 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** A self-signed membership motion on the Guestbook Plane. */ +enum class GuestbookAction( + val wire: String, +) { + JOIN("join"), + LEAVE("leave"), + ; + + companion object { + fun of(wire: String) = entries.firstOrNull { it.wire == wire } + } +} + +/** A parsed Guestbook join/leave: who ([member]), what ([action]), and invite attribution. */ +class GuestbookEntry( + val member: HexKey, + val action: GuestbookAction, + val createdAt: Long, + val inviteCreator: HexKey?, + val inviteLabel: String?, +) + +/** + * The Guestbook Plane (CORD-02): self-signed Joins and Leaves plus authorized + * Kicks that track membership motion. It is **off-consensus** — nothing in the + * Control or Chat planes depends on it — so it is best-effort presence, not + * authority. + * + * Rumor builders here are unsigned; seal + wrap them onto the community's + * Guestbook plane with + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. + */ +object Guestbook { + const val TAG_MS = "ms" + const val TAG_INVITE = "invite" + const val TAG_P = "p" + + /** A self-signed join (kind 3306), optionally attributing the invite used. */ + fun join( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ): Event = motion(memberPubKey, GuestbookAction.JOIN, createdAt, subMs, inviteCreator, inviteLabel) + + /** A self-signed leave (kind 3306). */ + fun leave( + memberPubKey: HexKey, + createdAt: Long, + subMs: Int? = null, + ): Event = motion(memberPubKey, GuestbookAction.LEAVE, createdAt, subMs, null, null) + + private fun motion( + memberPubKey: HexKey, + action: GuestbookAction, + createdAt: Long, + subMs: Int?, + inviteCreator: HexKey?, + inviteLabel: String?, + ): Event { + val tags = ArrayList>(2) + if (subMs != null) tags.add(arrayOf(TAG_MS, subMs.toString())) + if (inviteCreator != null) { + tags.add(if (inviteLabel != null) arrayOf(TAG_INVITE, inviteCreator, inviteLabel) else arrayOf(TAG_INVITE, inviteCreator)) + } + return RumorAssembler.assembleRumor(memberPubKey, createdAt, ConcordKinds.JOIN_LEAVE, tags.toTypedArray(), action.wire) + } + + /** + * An authorized Kick (kind 3309) directing [target] to leave. A Kick is only + * honored by clients when its signer holds [com.vitorpamplona.quartz.concord + * .cord04Roles.ConcordPermissions.KICK] and outranks the target — a Kick from + * a non-KICK holder is dropped (CORD-04 §The Three Removals). + */ + fun kick( + actorPubKey: HexKey, + target: HexKey, + createdAt: Long, + ): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, ConcordKinds.KICK, arrayOf(arrayOf(TAG_P, target)), "") + + /** Parses a Guestbook join/leave rumor, or null if it is not one. */ + fun parse(rumor: Event): GuestbookEntry? { + if (rumor.kind != ConcordKinds.JOIN_LEAVE) return null + val action = GuestbookAction.of(rumor.content) ?: return null + val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } + return GuestbookEntry( + member = rumor.pubKey, + action = action, + createdAt = rumor.createdAt, + inviteCreator = invite?.getOrNull(1), + inviteLabel = invite?.getOrNull(2), + ) + } + + /** The kick target's pubkey from a kind-3309 rumor, or null. */ + fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == ConcordKinds.KICK) rumor.tags.firstTagValue(TAG_P) else null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 6e8c02a583..ba9ff93571 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -84,3 +84,24 @@ class GrantEntity( val member: String = "", @SerialName("role_ids") val roleIds: List = emptyList(), ) + +/** + * A Channel's content (CORD-03). The channel id is the edition entity id. + * [private] selects derived-key visibility; [voice] flags an audio channel. + * A [deleted] channel is terminal — its id is never reused. + */ +@Serializable +class ChannelEntity( + val name: String = "", + val private: Boolean = false, + val voice: Boolean = false, + val deleted: Boolean = false, +) + +/** A community's Metadata content (CORD-02): display name, icon, and description. */ +@Serializable +class MetadataEntity( + val name: String = "", + val icon: String? = null, + val description: String? = null, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt new file mode 100644 index 0000000000..501e44697e --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt @@ -0,0 +1,83 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordCommunityStateTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val adminRole = "11".repeat(32) + + private fun edition( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + + @Test + fun foldsMetadataChannelsRolesAndAuthority() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""), + edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""), + edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""), + edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""), + edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), + edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""), + ) + + val state = ConcordCommunityState.fold(editions, owner) + + assertEquals("My Server", state.metadata?.name) + assertEquals("hi", state.metadata?.description) + + // deleted channel excluded; general + voice channel kept + assertEquals(2, state.channels.size) + assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name) + assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true) + assertNull(state.channels["c3".repeat(32)]) + + assertNotNull(state.roles[adminRole]) + assertEquals(1L, state.authority.rank(alice)) + assertFalse(state.dissolved) + } + + @Test + fun dissolutionTombstoneMarksCommunityDissolved() { + val editions = + listOf( + edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""), + edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), + ) + val state = ConcordCommunityState.fold(editions, owner) + assertTrue(state.dissolved) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt new file mode 100644 index 0000000000..71968b1d04 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GuestbookTest { + private val member = KeyPair().pubKey.toHexKey() + private val creator = KeyPair().pubKey.toHexKey() + private val target = KeyPair().pubKey.toHexKey() + + @Test + fun joinWithInviteAttributionRoundTrips() { + val rumor = Guestbook.join(member, createdAt = 1_700_000_000L, subMs = 128, inviteCreator = creator, inviteLabel = "Reddit") + assertEquals(ConcordKinds.JOIN_LEAVE, rumor.kind) + assertEquals("join", rumor.content) + + val entry = Guestbook.parse(rumor) + assertEquals(GuestbookAction.JOIN, entry?.action) + assertEquals(member, entry?.member) + assertEquals(creator, entry?.inviteCreator) + assertEquals("Reddit", entry?.inviteLabel) + } + + @Test + fun leaveParses() { + val entry = Guestbook.parse(Guestbook.leave(member, createdAt = 1L)) + assertEquals(GuestbookAction.LEAVE, entry?.action) + assertNull(entry?.inviteCreator) + } + + @Test + fun kickTargetsAMember() { + val rumor = Guestbook.kick(actorPubKey = creator, target = target, createdAt = 1L) + assertEquals(ConcordKinds.KICK, rumor.kind) + assertEquals(target, Guestbook.kickTarget(rumor)) + } + + @Test + fun parseIgnoresNonGuestbookRumors() { + assertNull(Guestbook.parse(Guestbook.kick(creator, target, 1L))) // kick is not a join/leave + } +} From 06fa80da65975a11a4fb42609cfbaa529ad12206 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:06:34 +0000 Subject: [PATCH 008/115] feat(concord): add invite-link codec and bundle key (CORD-05) - ConcordKeyDerivation.inviteBundleKey: derives the bundle decryption key from a link's 16-byte unlock token via hkdf32(token, "concord/invite-key") - InviteRelayDictionary: the v4 stock relay set + id<->url mapping - ConcordInviteLink: encode/decode the {base}/invite/{naddr}#{fragment} link and the [version=4][flags][relays?][token:16] fragment (stock-set flag, dictionary ids, wss:// host and full-url relay entries), rejecting non-v4 versions; builds the naddr (33301, link_signer, d="") and parses it back Tests cover stock/dictionary/literal/full-url relay round-trips, wrong-version rejection, full URL round-trip through naddr, and token-bound bundle key derivation. Green on :quartz:jvmTest. Pinned to Concord v2 (Armada) constants. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../cord05Invites/ConcordInviteLink.kt | 189 ++++++++++++++++++ .../cord05Invites/InviteRelayDictionary.kt | 56 ++++++ .../concord/crypto/ConcordKeyDerivation.kt | 10 + .../cord05Invites/ConcordInviteLinkTest.kt | 97 +++++++++ 4 files changed, 352 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt new file mode 100644 index 0000000000..3f32739fab --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -0,0 +1,189 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** The decoded contents of an invite-link URL fragment. */ +class InviteFragment( + /** The 16-byte unlock token (derives the bundle key; never sent to a server). */ + val token: ByteArray, + /** The resolved bootstrap relay URLs (stock set, or the encoded custom list). */ + val relays: List, + val usedStockRelays: Boolean, +) + +/** A parsed invite-link URL: its addressable pointer plus the private fragment. */ +class ParsedInviteLink( + val naddr: String, + val linkSignerPubKey: String, + val kind: Int, + val fragment: InviteFragment, +) + +/** + * Codec for Concord invite links (CORD-05): + * + * ``` + * {base}/invite/{naddr}#{fragment} + * ``` + * + * The `naddr` is a public NIP-19 pointer to the kind-33301 bundle + * `(33301, link_signer_pubkey, d="")`. The `#fragment` is **never sent to any + * server**: it is base64url of `[version=4][flags][relays?][token:16]`, carrying + * the 16-byte unlock token (→ [com.vitorpamplona.quartz.concord.crypto + * .ConcordKeyDerivation.inviteBundleKey]) and, when flag `0x01` is unset, up to + * three bootstrap relays encoded against [InviteRelayDictionary]. + * + * Pinned to the Concord v2 reference client for interop. + */ +object ConcordInviteLink { + const val VERSION = 4 + const val FLAG_STOCK_RELAYS = 0x01 + const val MAX_RELAYS = 3 + + private const val MARKER_WSS_HOST = 0 + private const val MARKER_FULL_URL = 255 + private const val WSS_PREFIX = "wss://" + private const val TOKEN_LEN = 16 + + /** + * Encodes the fragment for [token] and optional [relays]. Passing null or the + * exact stock set uses flag `0x01` and emits no relay bytes; otherwise up to + * [MAX_RELAYS] relays are encoded (dictionary id, `wss://` host, or full URL). + */ + @OptIn(ExperimentalEncodingApi::class) + fun encodeFragment( + token: ByteArray, + relays: List? = null, + ): String { + require(token.size == TOKEN_LEN) { "token must be $TOKEN_LEN bytes, was ${token.size}" } + val out = ArrayList(2 + TOKEN_LEN) + out.add(VERSION.toByte()) + + val useStock = relays == null || relays == InviteRelayDictionary.STOCK + if (useStock) { + out.add(FLAG_STOCK_RELAYS.toByte()) + } else { + require(relays!!.size <= MAX_RELAYS) { "at most $MAX_RELAYS relays, was ${relays.size}" } + out.add(0) + out.add(relays.size.toByte()) + for (r in relays) { + val id = InviteRelayDictionary.idOf(r) + when { + id != null -> out.add(id.toByte()) + r.startsWith(WSS_PREFIX) -> { + val host = r.substring(WSS_PREFIX.length).encodeToByteArray() + require(host.size <= 255) { "relay host too long" } + out.add(MARKER_WSS_HOST.toByte()) + out.add(host.size.toByte()) + host.forEach { out.add(it) } + } + else -> { + val url = r.encodeToByteArray() + require(url.size <= 255) { "relay url too long" } + out.add(MARKER_FULL_URL.toByte()) + out.add(url.size.toByte()) + url.forEach { out.add(it) } + } + } + } + } + token.forEach { out.add(it) } + return Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(out.toByteArray()) + } + + /** + * Decodes an invite [fragment]. Throws for a malformed fragment or a version + * other than [VERSION] (lower = legacy, higher = newer than this client). + * Unknown dictionary ids are skipped rather than aborting the parse. + */ + @OptIn(ExperimentalEncodingApi::class) + fun decodeFragment(fragment: String): InviteFragment { + val bytes = Base64.UrlSafe.withPadding(Base64.PaddingOption.PRESENT_OPTIONAL).decode(fragment) + require(bytes.size >= 2 + TOKEN_LEN) { "fragment too short" } + val version = bytes[0].toInt() and 0xFF + require(version == VERSION) { "unsupported invite fragment version $version" } + val flags = bytes[1].toInt() and 0xFF + + var pos = 2 + val relays = ArrayList() + var usedStock = false + if (flags and FLAG_STOCK_RELAYS != 0) { + relays.addAll(InviteRelayDictionary.STOCK) + usedStock = true + } else { + val count = bytes[pos++].toInt() and 0xFF + repeat(count) { + val marker = bytes[pos++].toInt() and 0xFF + when (marker) { + MARKER_WSS_HOST -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(WSS_PREFIX + bytes.decodeToString(pos, pos + len)) + pos += len + } + MARKER_FULL_URL -> { + val len = bytes[pos++].toInt() and 0xFF + relays.add(bytes.decodeToString(pos, pos + len)) + pos += len + } + else -> InviteRelayDictionary.urlOf(marker)?.let { relays.add(it) } // unknown id: skip + } + } + } + + require(bytes.size - pos == TOKEN_LEN) { "trailing bytes are not a $TOKEN_LEN-byte token" } + return InviteFragment(bytes.copyOfRange(pos, pos + TOKEN_LEN), relays, usedStock) + } + + /** Builds a full shareable invite URL under [base]. */ + fun buildUrl( + base: String, + linkSignerPubKey: String, + token: ByteArray, + relays: List? = null, + ): String { + val naddr = NAddress.create(ConcordKinds.INVITE_BUNDLE, linkSignerPubKey, "", null) + val trimmed = base.trimEnd('/') + return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" + } + + /** Parses a full invite URL back into its pointer + fragment, or null if malformed. */ + fun parseUrl(url: String): ParsedInviteLink? { + val hash = url.indexOf('#') + if (hash < 0) return null + val fragment = + try { + decodeFragment(url.substring(hash + 1)) + } catch (_: Exception) { + return null + } + val marker = url.indexOf("/invite/") + if (marker < 0) return null + val naddr = url.substring(marker + "/invite/".length, hash) + val parsed = NAddress.parse(naddr) ?: return null + if (parsed.kind != ConcordKinds.INVITE_BUNDLE) return null + return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt new file mode 100644 index 0000000000..701411e907 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/InviteRelayDictionary.kt @@ -0,0 +1,56 @@ +/* + * 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.quartz.concord.cord05Invites + +/** + * The invite-link relay dictionary (CORD-05 §Relay Dictionary), version 4, + * pinned to the Concord v2 reference client. Referencing a relay by its dictionary + * id keeps invite links compact; the stock set is selected by a single flag so the + * common invite carries zero relay bytes. + * + * Dictionary ids run 1–254. Id 0 is reserved as the "wss:// literal host" marker + * and 255 as the "full URL" marker in the fragment encoding (see [ConcordInviteLink]). + */ +object InviteRelayDictionary { + /** The stock relay set carried by flag 0x01 (the four v4 primaries). */ + val STOCK: List = + listOf( + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", + ) + + /** id → relay url, for the ids that fit in a single dictionary byte (1–254). */ + val BY_ID: Map = + mapOf( + 1 to "wss://jskitty.com/nostr", + 2 to "wss://asia.vectorapp.io/nostr", + 3 to "wss://relay.ditto.pub", + 4 to "wss://relay.dreamith.to", + ) + + private val ID_BY_URL: Map = BY_ID.entries.associate { (id, url) -> url to id } + + fun idOf(url: String): Int? = ID_BY_URL[url] + + fun urlOf(id: Int): String? = BY_ID[id] +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index d63f3b21ec..c261c1d94d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -180,6 +180,16 @@ object ConcordKeyDerivation { identity: String, ): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray()))) + // ---- CORD-05 invite bundle key -------------------------------------------- + + /** + * Derives the invite bundle decryption key from a link's 16-byte unlock + * [token] (CORD-05): `hkdf32(token, "concord/invite-key" ‖ 0x00)`. The token + * lives only in the URL fragment, so a server that sees the naddr can never + * open the bundle. + */ + fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY)) + // ---- CORD-06 rekey locator ------------------------------------------------ /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt new file mode 100644 index 0000000000..5e7dc437b7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -0,0 +1,97 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordInviteLinkTest { + private val token = ByteArray(16) { it.toByte() } + private val signer = KeyPair().pubKey.toHexKey() + + @Test + fun stockFragmentRoundTrips() { + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays = null)) + assertContentEquals(token, frag.token) + assertTrue(frag.usedStockRelays) + assertEquals(InviteRelayDictionary.STOCK, frag.relays) + + // passing the exact stock set also collapses to the stock flag + val fromStockList = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, InviteRelayDictionary.STOCK)) + assertTrue(fromStockList.usedStockRelays) + } + + @Test + fun dictionaryRelaysRoundTrip() { + val relays = listOf("wss://relay.ditto.pub", "wss://jskitty.com/nostr") // ids 3 and 1 + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertFalse(frag.usedStockRelays) + assertEquals(relays, frag.relays) + assertContentEquals(token, frag.token) + } + + @Test + fun literalHostAndFullUrlRelaysRoundTrip() { + val relays = listOf("wss://myrelay.example/nostr", "ws://plain.example") + val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertEquals(relays, frag.relays) + } + + @Test + @OptIn(ExperimentalEncodingApi::class) + fun rejectsWrongVersion() { + // craft a version-3 fragment: [3, 0x01, token...] + val bytes = byteArrayOf(3, 0x01) + token + val legacy = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + assertFailsWith { ConcordInviteLink.decodeFragment(legacy) } + } + + @Test + fun fullUrlRoundTripsThroughNaddr() { + val url = ConcordInviteLink.buildUrl("https://vector.chat", signer, token) + assertTrue(url.startsWith("https://vector.chat/invite/naddr")) + + val parsed = ConcordInviteLink.parseUrl(url) + assertNotNull(parsed) + assertEquals(signer, parsed.linkSignerPubKey) + assertEquals(ConcordKinds.INVITE_BUNDLE, parsed.kind) + assertContentEquals(token, parsed.fragment.token) + } + + @Test + fun inviteBundleKeyIsDeterministicAndTokenBound() { + val k = ConcordKeyDerivation.inviteBundleKey(token) + assertEquals(32, k.size) + assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token)) + assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey()) + } +} From b0e3594eaa47fc89b520232e7f12a89c8fb9f550 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:20:51 +0000 Subject: [PATCH 009/115] feat(concord): add rekey distribution (CORD-06) Non-ratcheted async key rotation to remove members from a channel or (root scope) the whole community, pinned to Concord v2 (Armada rekey.ts): - RekeyPayload: the 72-byte scope_id||epoch_be8||new_key blob codec - RekeyBlob: per-recipient {locator, wrapped} entry - ConcordRekey: blobFor (locator = recipient pseudonym; wrapped = base64 payload NIP-44-encrypted under the rotator<->recipient pairwise key), kind-3303 rumor tags (scope/newepoch/prevepoch/prevcommit/chunk) and content codec, and findNewKey (recipient computes their locator, matches, decrypts, verifies scope+epoch) with absence == removal Test proves remaining members recover the rotated key while a removed member finds no matching blob, and that the locator is epoch-bound. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord06Rekey/ConcordRekey.kt | 140 ++++++++++++++++++ .../quartz/concord/cord06Rekey/RekeyBlob.kt | 68 +++++++++ .../concord/cord06Rekey/ConcordRekeyTest.kt | 84 +++++++++++ 3 files changed, 292 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt new file mode 100644 index 0000000000..60745ca6c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -0,0 +1,140 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import kotlinx.serialization.builtins.ListSerializer +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Rekey distribution (CORD-06): non-ratcheted, asynchronous key rotation that + * removes members from a channel (or, in a Refounding, the whole community) while + * keeping the new key secret from those removed. + * + * The rotator publishes a kind-3303 rumor whose content is a JSON array of + * [RekeyBlob]s, one per remaining member. Each blob's `locator` is the recipient's + * pseudonym (public-input HKDF), and its `wrapped` field is the 72-byte + * [RekeyPayload] (base64 → NIP-44 under the rotator↔recipient pairwise key). A + * recipient computes their own locator, finds the matching blob, and decrypts the + * new key; a member with no matching blob across all chunks of a complete rotation + * has been removed. + * + * Pinned to the Concord v2 reference client for interop. + */ +object ConcordRekey { + const val TAG_SCOPE = "scope" + const val TAG_NEWEPOCH = "newepoch" + const val TAG_PREVEPOCH = "prevepoch" + const val TAG_PREVCOMMIT = "prevcommit" + const val TAG_CHUNK = "chunk" + + /** All-zero scope id marks a community_root refounding rather than a channel rekey. */ + val ROOT_SCOPE: ByteArray = ByteArray(32) + + /** + * Builds a rekey blob delivering [newKey] to one recipient. + * + * @param rotatorPrivKey the rotator's private key (their real identity) + * @param rotatorXOnly the rotator's x-only pubkey + * @param recipientXOnly the recipient's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun blobFor( + rotatorPrivKey: ByteArray, + rotatorXOnly: ByteArray, + recipientXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + newKey: ByteArray, + ): RekeyBlob { + val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val convKey = Nip44.v2.getConversationKey(rotatorPrivKey, recipientXOnly) + val wrapped = Nip44.v2.encrypt(payloadB64, convKey).encodePayload() + return RekeyBlob(locator, wrapped) + } + + /** The kind-3303 rumor tags for a rekey chunk. */ + fun tags( + scopeId: ByteArray, + newEpoch: Long, + prevEpoch: Long, + prevCommit: HexKey, + chunkIndex: Int, + chunkTotal: Int, + ): Array> = + arrayOf( + arrayOf(TAG_SCOPE, scopeId.toHexKey()), + arrayOf(TAG_NEWEPOCH, newEpoch.toString()), + arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), + arrayOf(TAG_PREVCOMMIT, prevCommit), + arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), + ) + + /** Serializes a chunk's blobs into the kind-3303 rumor content. */ + fun encodeContent(blobs: List): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs) + + /** Parses a kind-3303 rumor's content back into its blobs, or empty on error. */ + fun decodeContent(content: String): List = + try { + ConcordJson.instance.decodeFromString(ListSerializer(RekeyBlob.serializer()), content) + } catch (_: Exception) { + emptyList() + } + + const val KIND: Int = ConcordKinds.REKEY + + /** + * Finds the recipient's rotated key across the [blobs] of one or more chunks, + * or null if they were removed. Computes the recipient's locator, matches it, + * decrypts under the pairwise key, and verifies the payload's scope and epoch. + * + * @param recipientPrivKey the recipient's private key + * @param recipientXOnly the recipient's x-only pubkey + * @param rotatorXOnly the rotator's x-only pubkey + */ + @OptIn(ExperimentalEncodingApi::class) + fun findNewKey( + blobs: List, + recipientPrivKey: ByteArray, + recipientXOnly: ByteArray, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? { + val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null + return try { + val convKey = Nip44.v2.getConversationKey(recipientPrivKey, rotatorXOnly) + val payload = RekeyPayload.decode(Base64.Default.decode(Nip44.v2.decrypt(blob.wrapped, convKey))) ?: return null + if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null + payload.newKey + } catch (_: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt new file mode 100644 index 0000000000..e841183732 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/RekeyBlob.kt @@ -0,0 +1,68 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import kotlinx.serialization.Serializable + +/** + * One recipient's entry in a rekey (CORD-06): a [locator] (the recipient's + * pseudonym, so only they know it's for them) and the [wrapped] new key (the + * 72-byte payload, base64'd then NIP-44-encrypted under the rotator↔recipient + * pairwise key). + */ +@Serializable +class RekeyBlob( + val locator: String, + val wrapped: String, +) + +/** + * The 72-byte rekey payload: `scope_id[32] ‖ epoch_be8 ‖ new_key[32]` + * (CORD-06 §2). Fixed-width so a recipient can verify the scope and epoch it + * decrypts to match what they expected before adopting [newKey]. + */ +class RekeyPayload( + val scopeId: ByteArray, + val epoch: Long, + val newKey: ByteArray, +) { + fun encode(): ByteArray { + require(scopeId.size == 32) { "scopeId must be 32 bytes" } + require(newKey.size == 32) { "newKey must be 32 bytes" } + val out = ByteArray(SIZE) + scopeId.copyInto(out, 0) + ConcordKeyDerivation.writeBe64(out, 32, epoch) + newKey.copyInto(out, 40) + return out + } + + companion object { + const val SIZE = 72 + + fun decode(bytes: ByteArray): RekeyPayload? { + if (bytes.size != SIZE) return null + var epoch = 0L + for (i in 0 until 8) epoch = (epoch shl 8) or (bytes[32 + i].toLong() and 0xFF) + return RekeyPayload(bytes.copyOfRange(0, 32), epoch, bytes.copyOfRange(40, 72)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt new file mode 100644 index 0000000000..c0e251dc0a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyTest.kt @@ -0,0 +1,84 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ConcordRekeyTest { + private val rotator = KeyPair() + private val alice = KeyPair() + private val bob = KeyPair() + private val carol = KeyPair() // removed member + + private val scope = ByteArray(32) { 0x42 } + private val newEpoch = 1L + private val newKey = ByteArray(32) { 0x7E } + + private fun blobFor(recipient: KeyPair) = ConcordRekey.blobFor(rotator.privKey!!, rotator.pubKey, recipient.pubKey, scope, newEpoch, newKey) + + private fun find( + recipient: KeyPair, + blobs: List, + epoch: Long = newEpoch, + ) = ConcordRekey.findNewKey(blobs, recipient.privKey!!, recipient.pubKey, rotator.pubKey, scope, epoch) + + @Test + fun payloadEncodesAndDecodes() { + val decoded = RekeyPayload.decode(RekeyPayload(scope, 42, newKey).encode()) + assertContentEquals(scope, decoded?.scopeId) + assertEquals(42L, decoded?.epoch) + assertContentEquals(newKey, decoded?.newKey) + assertNull(RekeyPayload.decode(ByteArray(70))) // wrong size + } + + @Test + fun remainingMembersGetTheKeyAndRemovedMembersDoNot() { + // Rotator distributes the new key to Alice and Bob, but not Carol. + val blobs = listOf(blobFor(alice), blobFor(bob)) + val content = ConcordRekey.encodeContent(blobs) + val roundTripped = ConcordRekey.decodeContent(content) + + assertContentEquals(newKey, find(alice, roundTripped)) + assertContentEquals(newKey, find(bob, roundTripped)) + assertNull(find(carol, roundTripped)) // no blob for Carol ⇒ removed + } + + @Test + fun wrongEpochDoesNotMatch() { + val blobs = listOf(blobFor(alice)) + assertNull(find(alice, blobs, epoch = 2L)) // locator is epoch-bound + } + + @Test + fun tagsCarryScopeEpochAndChunk() { + val tags = ConcordRekey.tags(scope, newEpoch, prevEpoch = 0, prevCommit = "ab".repeat(32), chunkIndex = 1, chunkTotal = 3) + assertEquals(scope.toHexKey(), tags.first { it[0] == ConcordRekey.TAG_SCOPE }[1]) + assertEquals("1", tags.first { it[0] == ConcordRekey.TAG_NEWEPOCH }[1]) + val chunk = tags.first { it[0] == ConcordRekey.TAG_CHUNK } + assertEquals("1", chunk[1]) + assertEquals("3", chunk[2]) + } +} From b96960db889290032bff6a7fb74e8e96db60994d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:48:46 +0000 Subject: [PATCH 010/115] feat(concord): add private joined-communities list (kind 13302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-51 analog for returning to signed-up Concord communities (CORD-05): - ConcordCommunityListEntry: per-community credentials needed to re-derive planes on any device (id, owner, ownerSalt, current root + rootEpoch, past heldRoots, privateChannels keys, relays, cached name) - ConcordCommunityList: build/parse the replaceable kind-13302 event, NIP-44 self-encrypted so relays store only ciphertext, plus a cross-device merge that keeps the freshest root epoch per community Channels are intentionally not listed — holding the root and folding the Control Plane yields them. Tests cover self-encrypted round-trip, that only the owner can decrypt, and epoch-wins merge. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../cord02Community/ConcordCommunityList.kt | 117 ++++++++++++++++++ .../ConcordCommunityListTest.kt | 80 ++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt new file mode 100644 index 0000000000..7629f174a9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -0,0 +1,117 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer + +/** A past root key for a specific epoch, kept so historical channel keys stay derivable. */ +@Serializable +class HeldRoot( + val epoch: Long, + val key: String, +) + +/** A private channel's delivered key at a given epoch (for private channels the member can read). */ +@Serializable +class PrivateChannelKey( + val channelId: String, + val key: String, + val epoch: Long, +) + +/** + * One joined Concord community in the member's private list. Carries everything + * needed to re-derive the community's planes on any device: identity ([id], + * [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past + * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached + * display [name]. + */ +@Serializable +class ConcordCommunityListEntry( + val id: String, + val owner: String, + val ownerSalt: String, + val root: String, + val rootEpoch: Long = 0, + val heldRoots: List = emptyList(), + val privateChannels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", +) + +/** + * The member's private, self-encrypted list of joined Concord communities + * (kind [ConcordKinds.COMMUNITY_LIST] = 13302, CORD-05) — the NIP-51 analog that + * lets a client return to the groups the user signed up for. Replaceable and + * NIP-44-encrypted to the member's own key, so relays store only ciphertext. + * + * (Channels are not listed here: once the [root] is held, folding the Control + * Plane yields the community's channels.) + */ +object ConcordCommunityList { + /** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */ + suspend fun build( + signer: NostrSigner, + entries: List, + createdAt: Long, + ): Event { + val json = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries) + val content = signer.nip44Encrypt(json, signer.pubKey) + return signer.sign(createdAt, ConcordKinds.COMMUNITY_LIST, emptyArray(), content) + } + + /** Decrypts and parses a kind-13302 list event with [signer], or empty on failure. */ + suspend fun parse( + event: Event, + signer: NostrSigner, + ): List { + if (event.kind != ConcordKinds.COMMUNITY_LIST) return emptyList() + return try { + val json = signer.nip44Decrypt(event.content, signer.pubKey) + ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json) + } catch (_: Exception) { + emptyList() + } + } + + /** + * Merges two decrypted lists (e.g. from two devices), keeping one entry per + * community id. When both hold the same community, the one with the higher + * [ConcordCommunityListEntry.rootEpoch] wins so the freshest access key + * survives. + */ + fun merge( + a: List, + b: List, + ): List { + val byId = LinkedHashMap() + for (e in a + b) { + val existing = byId[e.id] + if (existing == null || e.rootEpoch > existing.rootEpoch) byId[e.id] = e + } + return byId.values.toList() + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt new file mode 100644 index 0000000000..e883bc4b50 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -0,0 +1,80 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordCommunityListTest { + private val signer = NostrSignerInternal(KeyPair()) + private val other = NostrSignerInternal(KeyPair()) + + private fun entry( + id: String, + name: String, + epoch: Long = 0, + ) = ConcordCommunityListEntry( + id = id, + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = epoch, + relays = listOf("wss://relay.example"), + name = name, + ) + + @Test + fun selfEncryptedListRoundTrips() = + runTest { + val entries = listOf(entry("11".repeat(32), "Gamers"), entry("22".repeat(32), "Nostrichs")) + val event = ConcordCommunityList.build(signer, entries, createdAt = 1_700_000_000L) + + assertEquals(ConcordKinds.COMMUNITY_LIST, event.kind) + assertFalse(event.content.contains("Gamers")) // encrypted on the wire + + val parsed = ConcordCommunityList.parse(event, signer) + assertEquals(2, parsed.size) + assertEquals("Gamers", parsed[0].name) + assertEquals(listOf("wss://relay.example"), parsed[0].relays) + } + + @Test + fun onlyTheOwnerCanDecrypt() = + runTest { + val event = ConcordCommunityList.build(signer, listOf(entry("11".repeat(32), "Secret")), createdAt = 1L) + assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing + } + + @Test + fun mergeKeepsFreshestEpochPerCommunity() { + val a = listOf(entry("11".repeat(32), "Old", epoch = 1)) + val b = listOf(entry("11".repeat(32), "New", epoch = 3), entry("22".repeat(32), "Other", epoch = 0)) + val merged = ConcordCommunityList.merge(a, b) + assertEquals(2, merged.size) + assertEquals("New", merged.first { it.id == "11".repeat(32) }.name) // higher epoch wins + } +} From f42f587adef23d6b7927428b685522b81bf458cb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:53:13 +0000 Subject: [PATCH 011/115] feat(concord): add community creation factory and entity coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the "create a community" path (CORD-02 Genesis), pinned to Armada (concord-v2 control.ts/community.ts): - ConcordKeyDerivation: control/guestbook plane keys and the keyless entity coordinates — grantCoordinate = hkdf32(communityId, "concord/grant"||member), banlistCoordinate (ZERO32 id), inviteLinksCoordinate (creator) - ControlEditionBuilder: assembles kind-3308 edition rumors (vsk/eid/ev/ep/vac), the inverse of ControlEdition.fromRumor - MetadataEntity gains relays - ConcordCommunityFactory.create: mints owner_salt + self-certifying community_id, an independent community_root, and two owner-signed genesis editions (metadata with eid=communityId, and a public #general channel) as plaintext-seal wraps on the Control Plane at epoch 0 Test creates a community, verifies the id commitment, opens the genesis wraps (20014 seals, owner-authored), folds them into live ConcordCommunityState with a #general channel and owner authority, and confirms one owner yields distinct communities. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ConcordCommunityFactory.kt | 141 ++++++++++++++++++ .../cord04Roles/ControlEditionBuilder.kt | 68 +++++++++ .../concord/cord04Roles/ControlEntities.kt | 6 +- .../concord/crypto/ConcordKeyDerivation.kt | 35 +++++ .../ConcordCommunityFactoryTest.kt | 104 +++++++++++++ 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt new file mode 100644 index 0000000000..314bc953cd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -0,0 +1,141 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A freshly created Concord community: its self-certifying identity and access + * secrets, plus the genesis Control Plane wraps to publish and the equivalent + * editions to fold locally. + */ +class NewConcordCommunity( + val communityId: ByteArray, + val ownerPubKey: String, + val ownerSalt: ByteArray, + val communityRoot: ByteArray, + val rootEpoch: Long, + val generalChannelId: ByteArray, + val controlPlane: GroupKey, + /** The kind-1059 control-plane wraps to publish (metadata + #general). */ + val genesisWraps: List, + /** The same editions as parsed [ControlEdition]s, for immediate local folding. */ + val genesisEditions: List, +) { + val communityIdHex: String get() = communityId.toHexKey() + val generalChannelIdHex: String get() = generalChannelId.toHexKey() +} + +/** + * Creates new Concord communities (CORD-02 Genesis). + * + * `create` mints a random `owner_salt`, derives the self-certifying + * `community_id = sha256("concord/community" ‖ owner ‖ salt)`, generates an + * independent random `community_root` (so access can rotate while identity stays + * fixed), and emits exactly two owner-signed genesis editions — the community + * metadata and a public `#general` channel — as plaintext-seal wraps on the + * Control Plane at epoch 0. + */ +object ConcordCommunityFactory { + const val GENERAL_CHANNEL_NAME = "general" + + suspend fun create( + ownerSigner: NostrSigner, + name: String, + createdAt: Long, + description: String? = null, + relays: List = emptyList(), + icon: String? = null, + ): NewConcordCommunity { + val ownerXOnly = ownerSigner.pubKey.hexToByteArray() + val ownerSalt = ConcordKeyDerivation.newOwnerSalt() + val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt) + val communityRoot = RandomInstance.bytes(32) + val generalChannelId = RandomInstance.bytes(32) + val rootEpoch = 0L + val controlPlane = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + + val metadataJson = + ConcordJson.instance.encodeToString( + MetadataEntity.serializer(), + MetadataEntity(name = name, icon = icon, description = description, relays = relays), + ) + val channelJson = + ConcordJson.instance.encodeToString( + ChannelEntity.serializer(), + ChannelEntity(name = GENERAL_CHANNEL_NAME, private = false), + ) + + val metadataRumor = + ControlEditionBuilder.rumor( + authorPubKey = ownerSigner.pubKey, + entityKind = ControlEntityKind.METADATA, + entityId = communityId, // metadata eid == community id + version = 0, + prevHash = null, + content = metadataJson, + createdAt = createdAt, + ) + val channelRumor = + ControlEditionBuilder.rumor( + authorPubKey = ownerSigner.pubKey, + entityKind = ControlEntityKind.CHANNEL, + entityId = generalChannelId, // channel eid == channel id + version = 0, + prevHash = null, + content = channelJson, + createdAt = createdAt, + ) + + // Control Plane uses plaintext (20014) seals so signatures survive re-encryption across epochs. + val metadataWrap = ConcordStreamEnvelope.wrap(metadataRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt) + val channelWrap = ConcordStreamEnvelope.wrap(channelRumor, controlPlane, ownerSigner, encrypted = false, createdAt = createdAt) + + return NewConcordCommunity( + communityId = communityId, + ownerPubKey = ownerSigner.pubKey, + ownerSalt = ownerSalt, + communityRoot = communityRoot, + rootEpoch = rootEpoch, + generalChannelId = generalChannelId, + controlPlane = controlPlane, + genesisWraps = listOf(metadataWrap, channelWrap), + genesisEditions = + listOfNotNull( + ControlEdition.fromRumor(metadataRumor), + ControlEdition.fromRumor(channelRumor), + ), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt new file mode 100644 index 0000000000..393e4c4ec6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt @@ -0,0 +1,68 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * Builds unsigned kind-3308 Control Plane edition rumors (the inverse of + * [ControlEdition.fromRumor]). Seal these with a plaintext (20014) seal and wrap + * them on the community's Control Plane so the author's signature survives + * re-encryption across epochs. + */ +object ControlEditionBuilder { + /** + * Assembles a control edition rumor for [entityKind]/[entityId] at [version]. + * Pass [prevHash] to chain onto the previous edition (null for genesis) and + * [authorityCitation] to pin the Grant the [authorPubKey] acts under. + */ + fun rumor( + authorPubKey: HexKey, + entityKind: ControlEntityKind, + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + createdAt: Long, + authorityCitation: AuthorityCitation? = null, + ): Event { + val tags = ArrayList>(5) + tags.add(arrayOf(ControlEdition.TAG_VSK, entityKind.wire)) + tags.add(arrayOf(ControlEdition.TAG_EID, entityId.toHexKey())) + tags.add(arrayOf(ControlEdition.TAG_EV, version.toString())) + if (prevHash != null) tags.add(arrayOf(ControlEdition.TAG_EP, prevHash.toHexKey())) + if (authorityCitation != null) { + tags.add( + arrayOf( + ControlEdition.TAG_VAC, + authorityCitation.grantId.toHexKey(), + authorityCitation.grantVersion.toString(), + authorityCitation.grantHash.toHexKey(), + ), + ) + } + return RumorAssembler.assembleRumor(authorPubKey, createdAt, ConcordKinds.CONTROL, tags.toTypedArray(), content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index ba9ff93571..9673a26516 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -98,10 +98,14 @@ class ChannelEntity( val deleted: Boolean = false, ) -/** A community's Metadata content (CORD-02): display name, icon, and description. */ +/** + * A community's Metadata content (CORD-02): display [name], optional [icon] and + * [description], and the community's bootstrap [relays]. Client-extensible. + */ @Serializable class MetadataEntity( val name: String = "", val icon: String? = null, val description: String? = null, + val relays: List = emptyList(), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index c261c1d94d..b15b2bc56f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -180,6 +180,41 @@ object ConcordKeyDerivation { identity: String, ): ByteArray = hkdf32(voiceMediaKey, buildInfo(ConcordLabels.VOICE_SENDER, sha256(identity.encodeToByteArray()))) + // ---- Plane keys (CORD-02) ------------------------------------------------- + + /** The Control Plane address for a community at [epoch] (holders of the root only). */ + fun controlPlaneKey( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.CONTROL, communityRoot, communityId, epoch) + + /** The Guestbook Plane address for a community at [epoch]. */ + fun guestbookPlaneKey( + communityRoot: ByteArray, + communityId: ByteArray, + epoch: Long, + ): GroupKey = groupKey(ConcordLabels.GUESTBOOK, communityRoot, communityId, epoch) + + // ---- Control entity coordinates (CORD-04) --------------------------------- + // Keyless coordinates: the community id is the HKDF ikm; distinct labels and + // id bytes give each entity kind its own address. All raw hkdf32 (32 bytes). + + /** The Grant entity id for a member: `hkdf32(communityId, "concord/grant" ‖ 0x00 ‖ member)`. */ + fun grantCoordinate( + communityId: ByteArray, + memberXOnly: ByteArray, + ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly)) + + /** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */ + fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32))) + + /** The invite-registry entity id for a creator: `hkdf32(communityId, "concord/invite-links" ‖ 0x00 ‖ creator)`. */ + fun inviteLinksCoordinate( + communityId: ByteArray, + creatorXOnly: ByteArray, + ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.INVITE_LINKS, creatorXOnly)) + // ---- CORD-05 invite bundle key -------------------------------------------- /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt new file mode 100644 index 0000000000..0c234713ca --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -0,0 +1,104 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordCommunityFactoryTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun createsSelfCertifyingCommunityWithGenesisEditions() = + runTest { + val community = + ConcordCommunityFactory.create( + ownerSigner = owner, + name = "Nostrichs", + createdAt = 1_700_000_000L, + description = "a cozy place", + relays = listOf("wss://relay.example"), + ) + + // community_id is the self-certifying commitment to owner + salt + assertContentEquals( + ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), community.ownerSalt), + community.communityId, + ) + + // Two genesis wraps, both authored by the Control Plane address. + assertEquals(2, community.genesisWraps.size) + community.genesisWraps.forEach { + assertEquals(ConcordStreamEnvelope.KIND_WRAP, it.kind) + assertEquals(community.controlPlane.publicKeyHex, it.pubKey) + } + + // Genesis wraps open with plaintext (20014) seals, authored by the owner. + val opened = community.genesisWraps.map { ConcordStreamEnvelope.open(it, community.controlPlane) } + opened.forEach { + assertEquals(ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT, it.sealKind) + assertEquals(owner.pubKey, it.author) + } + } + + @Test + fun genesisFoldsToLiveCommunityStateWithGeneralChannelAndOwnerAuthority() = + runTest { + val community = + ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example")) + + val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey) + + assertEquals("Gamers", state.metadata?.name) + assertEquals(listOf("wss://r.example"), state.metadata?.relays) + + val general = state.channels[community.generalChannelIdHex] + assertNotNull(general) + assertEquals(ConcordCommunityFactory.GENERAL_CHANNEL_NAME, general.definition.name) + assertFalse(general.definition.private) + + // The owner is supreme from genesis; no channels are private, none deleted. + assertTrue(state.authority.isOwner(owner.pubKey)) + assertEquals(0L, state.authority.rank(owner.pubKey)) + assertFalse(state.dissolved) + } + + @Test + fun differentCommunitiesFromSameOwnerHaveDistinctIds() = + runTest { + val a = ConcordCommunityFactory.create(owner, "A", 1L) + val b = ConcordCommunityFactory.create(owner, "B", 1L) + // distinct salts ⇒ distinct ids (one owner, many communities) + assertFalse(a.communityIdHex == b.communityIdHex) + assertFalse(a.communityRoot.toHexKey() == b.communityRoot.toHexKey()) + } +} From 5669414de7e37fa2e71306464f1e59c71acecf3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:04:46 +0000 Subject: [PATCH 012/115] feat(concord): add invite bundle (33301) and full join flow Completes the public invite path (CORD-05), pinned to Concord v2 (Armada invite.ts): - CommunityInvite: the bundle contents with exact snake_case field names (community_id, owner, owner_salt, community_root, root_epoch, channels[], relays, name, icon, expires_at, creator_npub, label) + ImagePointer/InviteChannel - ConcordInviteBundle: build/parse the kind-33301 event (content = nip44(CommunityInvite, inviteBundleKey(token)); tags d="",vsk="6"; signed by a per-link signer), self-certification validate (owner+salt reproduce community_id), expiry check, and mintLink (fresh token + link signer -> bundle event + shareable URL) End-to-end test: create a community, mint an invite link, a stranger parses the URL, decrypts the bundle with the fragment token, validates the owner commitment, reconstructs the root, and reads the genesis #general channel. Wrong-token and forged-owner rejections covered. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord05Invites/CommunityInvite.kt | 69 ++++++++++ .../cord05Invites/ConcordInviteBundle.kt | 122 ++++++++++++++++++ .../ConcordInviteJoinFlowTest.kt | 116 +++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt new file mode 100644 index 0000000000..95d94ffc61 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -0,0 +1,69 @@ +/* + * 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.quartz.concord.cord05Invites + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** An encrypted-media image reference (CORD-02): where the bytes are and how to decrypt them. */ +@Serializable +class ImagePointer( + val url: String = "", + val key: String = "", + val nonce: String = "", + val hash: String = "", +) + +/** A channel grant carried in an invite: its id, delivered [key], [epoch], and [name]. */ +@Serializable +class InviteChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", +) + +/** + * The contents of a Concord invite (CORD-05) — everything a joiner needs to + * become a member: the self-certifying [communityId] with its [owner]/[ownerSalt] + * proof, the access [communityRoot] at [rootEpoch], per-[channels] grants, + * bootstrap [relays], display [name]/[icon], optional [expiresAt] and creator + * attribution. + * + * Field names are pinned to the Concord v2 reference client (snake_case on the + * wire) so bundles interoperate. This object is JSON-serialized and encrypted — + * into a kind-33301 bundle (link invites) or a NIP-59 giftwrap (direct invites). + */ +@Serializable +class CommunityInvite( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long = 0, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + val icon: ImagePointer? = null, + @SerialName("expires_at") val expiresAt: Long? = null, + @SerialName("creator_npub") val creatorNpub: String? = null, + val label: String? = null, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt new file mode 100644 index 0000000000..0d301703a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -0,0 +1,122 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.utils.RandomInstance + +/** A freshly minted public invite link: the shareable URL, the link keys, and the bundle to publish. */ +class MintedInviteLink( + val url: String, + val linkSignerPubKey: String, + val linkSignerPrivKey: ByteArray, + val token: ByteArray, + val bundleEvent: Event, +) + +/** + * The public invite bundle (CORD-05): a kind-33301 addressable event whose + * content is the [CommunityInvite] NIP-44-encrypted under the bundle key derived + * from the link's 16-byte unlock token. The event is signed by a per-link + * `link_signer` keypair (so re-posting refreshes keys) and tagged + * `["d",""],["vsk","6"]`. + * + * A server that indexes the naddr never holds the token, so it can never open the + * bundle. Pinned to the Concord v2 reference client. + */ +object ConcordInviteBundle { + const val KIND = ConcordKinds.INVITE_BUNDLE + const val TAG_D = "d" + const val TAG_VSK = "vsk" + const val VSK_LIVE = "6" + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + + /** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */ + fun build( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + val content = Nip44.v2.encrypt(json(invite), bundleKey).encodePayload() + val signer = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)) + return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_D, ""), arrayOf(TAG_VSK, VSK_LIVE)), content) + } + + /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ + fun parse( + event: Event, + token: ByteArray, + ): CommunityInvite? { + if (event.kind != KIND) return null + return try { + val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) + ConcordJson.decodeOrNull(Nip44.v2.decrypt(event.content, bundleKey)) + } catch (_: Exception) { + null + } + } + + /** + * Validates that an [invite]'s owner + salt actually reproduce its + * community_id (CORD-02 self-certification), so a bundle can't smuggle a false + * owner or a fake key for a real community. + */ + fun validate(invite: CommunityInvite): Boolean { + val owner = invite.owner.hexToByteArrayOrNull() ?: return false + val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false + return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId + } + + /** True if the invite has an expiry in the past (blocks joining; preview still renders). Time in unix ms. */ + fun isExpired( + invite: CommunityInvite, + nowMs: Long, + ): Boolean = invite.expiresAt?.let { it < nowMs } ?: false + + /** + * Mints a complete public invite link for [invite]: generates a fresh 16-byte + * token and a per-link signer, builds the bundle event and the shareable + * `{base}/invite/{naddr}#{fragment}` URL (with optional bootstrap [relays]). + */ + fun mintLink( + base: String, + invite: CommunityInvite, + createdAt: Long, + relays: List? = null, + ): MintedInviteLink { + val token = RandomInstance.bytes(16) + val linkSigner = KeyPair() + val bundleEvent = build(linkSigner.privKey!!, token, invite, createdAt) + val url = ConcordInviteLink.buildUrl(base, linkSigner.pubKey.toHexKey(), token, relays) + return MintedInviteLink(url, linkSigner.pubKey.toHexKey(), linkSigner.privKey, token, bundleEvent) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt new file mode 100644 index 0000000000..3d365679c8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -0,0 +1,116 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The full public-invite path: create a community → mint an invite link → a + * stranger redeems the link, reconstructs the root, and reads the community's + * genesis Control Plane. This is the create-and-invite flow the app drives. + */ +class ConcordInviteJoinFlowTest { + private val owner = NostrSignerInternal(KeyPair()) + + private suspend fun inviteFor(community: com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity) = + CommunityInvite( + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + communityRoot = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + @Test + fun createMintRedeemAndRead() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) + + // The joiner only has the URL. Extract the token from the private fragment. + val parsedUrl = ConcordInviteLink.parseUrl(minted.url) + assertNotNull(parsedUrl) + assertEquals(minted.linkSignerPubKey, parsedUrl.linkSignerPubKey) + + // Decrypt the fetched bundle with that token and verify self-certification. + val invite = ConcordInviteBundle.parse(minted.bundleEvent, parsedUrl.fragment.token) + assertNotNull(invite) + assertTrue(ConcordInviteBundle.validate(invite)) + assertEquals(community.communityIdHex, invite.communityId) + + // Reconstruct the root, derive the Control Plane, and read the genesis. + val controlPlane = + ConcordKeyDerivation.controlPlaneKey( + invite.communityRoot.hexToByteArray(), + invite.communityId.hexToByteArray(), + invite.rootEpoch, + ) + val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) } + val state = ConcordCommunityState.fold(editions, invite.owner) + assertEquals("Nostrichs", state.metadata?.name) + assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member + } + + @Test + fun wrongTokenCannotOpenTheBundle() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Secret", createdAt = 1L) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + assertNull(ConcordInviteBundle.parse(minted.bundleEvent, ByteArray(16) { 0x01 })) // random token fails + } + + @Test + fun validateRejectsForgedOwner() { + // owner + salt that do not reproduce the claimed community_id + val forged = + CommunityInvite( + communityId = "00".repeat(32), + owner = KeyPair().pubKey.toHexKey(), + ownerSalt = "aa".repeat(32), + communityRoot = "bb".repeat(32), + ) + assertFalse(ConcordInviteBundle.validate(forged)) + } + + @Test + fun expiryBlocksJoiningButNotPreview() { + val invite = CommunityInvite("id", "o", "s", "r", expiresAt = 1_000L) + assertTrue(ConcordInviteBundle.isExpired(invite, nowMs = 2_000L)) + assertFalse(ConcordInviteBundle.isExpired(invite, nowMs = 500L)) + } +} From 5ea59ca137e3b491e6e75cfcca8807b805c39275 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:08:07 +0000 Subject: [PATCH 013/115] feat(concord): add voice presence and blind-broker token (CORD-07) - VoicePresence: kind-23313 join/left presence rumors bound to the channel/epoch and carrying the SFU identity + broker, with heartbeat/stale constants and a verifiedParticipants fold that renders an identity only when exactly one author claims it (contested identities stay unverified) - ConcordBrokerToken: the NIP-98-style kind-27235 token request signed by the channel's derived voice signer key (its pubkey is the SFU room name), the 'Authorization: Concord ' header, and the /.well-known/concord/av/ path Voice key derivation (voice_signer/voice_media/voice_sender) already lives in ConcordKeyDerivation. Tests cover presence round-trip, uncontested-only verification, staleness, and that the broker token is signed by the voice-room key. Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord07Voice/ConcordBrokerToken.kt | 66 ++++++++++ .../concord/cord07Voice/VoicePresence.kt | 124 ++++++++++++++++++ .../concord/cord07Voice/ConcordVoiceTest.kt | 87 ++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt new file mode 100644 index 0000000000..5c509a1b65 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordBrokerToken.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * The blind-broker token request (CORD-07 §2). A member proves Channel membership + * to a stateless SFU broker by signing a NIP-98-style kind-27235 event with the + * Channel's derived **voice signer key** — whose public key is the SFU room name, + * so only members (who can derive it) can mint valid requests. The broker holds + * no Community secret. + * + * The request rides an `Authorization: Concord ` header against + * `GET /.well-known/concord/av/`. + */ +object ConcordBrokerToken { + const val KIND = 27235 // NIP-98 HTTP auth + const val AUTH_SCHEME = "Concord" + const val TAG_URL = "u" + const val TAG_METHOD = "method" + + /** The broker path for a voice room (the room = the voice signer's x-only pubkey hex). */ + fun wellKnownPath(voiceRoomHex: String): String = "/.well-known/concord/av/$voiceRoomHex" + + /** + * Builds the kind-27235 auth event for [url]/[method], signed by the channel's + * [voiceSigner] key (its public key is the voice room / SFU name). + */ + fun buildAuthEvent( + voiceSigner: GroupKey, + url: String, + createdAt: Long, + method: String = "GET", + ): Event { + val signer = NostrSignerSync(KeyPair(privKey = voiceSigner.secretKey)) + return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_URL, url), arrayOf(TAG_METHOD, method)), "") + } + + /** The `Authorization` header value carrying a base64 of the signed auth [event]. */ + @OptIn(ExperimentalEncodingApi::class) + fun authorizationHeader(event: Event): String = "$AUTH_SCHEME " + Base64.Default.encode(event.toJson().encodeToByteArray()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt new file mode 100644 index 0000000000..9a0eb88365 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt @@ -0,0 +1,124 @@ +/* + * 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.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** A parsed voice presence: who is in the call, under which SFU [identity], and on which [broker]. */ +class VoicePresenceInfo( + val author: HexKey, + val channelId: HexKey?, + val epoch: Long?, + val joined: Boolean, + val identity: String?, + val broker: String?, + val createdAt: Long, +) + +/** + * Voice/video presence (CORD-07 §4): ephemeral kind-23313 rumors sealed on the + * Channel plane (like Chat Plane messages), announcing that a member is in the + * call under a broker-assigned SFU [VoicePresenceInfo.identity]. + * + * A participant renders as a verified member only when **exactly one author's + * fresh signed presence** claims an identity ([verifiedParticipants]); contested + * identities render unverified. Presence is heartbeated every + * [HEARTBEAT_MS] and considered absent after [STALE_MS]. + */ +object VoicePresence { + const val KIND = ConcordKinds.VOICE_PRESENCE + const val CONTENT_JOINED = "joined" + const val CONTENT_LEFT = "left" + const val TAG_IDENTITY = "identity" + const val TAG_BROKER = "broker" + + const val HEARTBEAT_MS = 30_000L + const val STALE_MS = 90_000L + + /** A "joined" presence bound to the channel/epoch, carrying the SFU [identity] and optional [broker]. */ + fun joined( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + identity: String, + createdAt: Long, + broker: String? = null, + subMs: Int? = null, + ): Event { + val tags = ArrayList>() + tags.addAll(ChannelChat.bindingTags(channelId, epoch)) + tags.add(arrayOf(TAG_IDENTITY, identity)) + if (broker != null) tags.add(arrayOf(TAG_BROKER, broker)) + if (subMs != null) tags.add(arrayOf("ms", subMs.toString())) + return RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, tags.toTypedArray(), CONTENT_JOINED) + } + + /** A "left" presence bound to the channel/epoch. */ + fun left( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, ChannelChat.bindingTags(channelId, epoch), CONTENT_LEFT) + + fun parse(rumor: Event): VoicePresenceInfo? { + if (rumor.kind != KIND) return null + return VoicePresenceInfo( + author = rumor.pubKey, + channelId = ChannelChat.channelOf(rumor), + epoch = ChannelChat.epochOf(rumor), + joined = rumor.content == CONTENT_JOINED, + identity = rumor.tags.firstTagValue(TAG_IDENTITY), + broker = rumor.tags.firstTagValue(TAG_BROKER), + createdAt = rumor.createdAt, + ) + } + + /** True if [presence] is within [STALE_MS] of [nowMs] (createdAt is unix seconds). */ + fun isFresh( + presence: VoicePresenceInfo, + nowMs: Long, + ): Boolean = nowMs - presence.createdAt * 1000 <= STALE_MS + + /** + * Maps each SFU identity to its single verified author across the given fresh + * [presences]. An identity claimed by zero or more-than-one author is omitted + * (contested identities render unverified). + */ + fun verifiedParticipants(presences: List): Map { + val claimants = HashMap>() + for (p in presences) { + if (!p.joined) continue + val id = p.identity ?: continue + claimants.getOrPut(id) { HashSet() }.add(p.author) + } + val out = HashMap() + for ((id, authors) in claimants) { + if (authors.size == 1) out[id] = authors.first() + } + return out + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt new file mode 100644 index 0000000000..f7db089e9d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/ConcordVoiceTest.kt @@ -0,0 +1,87 @@ +/* + * 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.quartz.concord.cord07Voice + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordVoiceTest { + private val alice = KeyPair().pubKey.toHexKey() + private val bob = KeyPair().pubKey.toHexKey() + private val channelId = "42".repeat(32) + + @Test + fun presenceRoundTrips() { + val rumor = VoicePresence.joined(alice, channelId, epoch = 0, identity = "sfu-abc", createdAt = 1_700_000_000L, broker = "https://broker.example") + val info = VoicePresence.parse(rumor) + assertEquals(VoicePresence.KIND, rumor.kind) + assertEquals("sfu-abc", info?.identity) + assertEquals("https://broker.example", info?.broker) + assertEquals(channelId, info?.channelId) + assertEquals(0L, info?.epoch) + assertTrue(info?.joined == true) + } + + @Test + fun onlyUncontestedIdentitiesVerify() { + val aliceP = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-alice", 1L))!! + val bobP = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-bob", 1L))!! + // both Alice and Bob claim the same identity -> contested + val contestedA = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id-x", 1L))!! + val contestedB = VoicePresence.parse(VoicePresence.joined(bob, channelId, 0, "id-x", 1L))!! + + val verified = VoicePresence.verifiedParticipants(listOf(aliceP, bobP, contestedA, contestedB)) + assertEquals(alice, verified["id-alice"]) + assertEquals(bob, verified["id-bob"]) + assertFalse(verified.containsKey("id-x")) // contested identity omitted + } + + @Test + fun stalePresenceIsNotFresh() { + val info = VoicePresence.parse(VoicePresence.joined(alice, channelId, 0, "id", createdAt = 1_000L))!! + // createdAt is unix seconds; 1_000s -> 1_000_000ms + assertTrue(VoicePresence.isFresh(info, nowMs = 1_000_000L + VoicePresence.STALE_MS)) + assertFalse(VoicePresence.isFresh(info, nowMs = 1_000_000L + VoicePresence.STALE_MS + 1)) + } + + @Test + fun brokerTokenIsSignedByTheVoiceRoomKey() { + val channelSecret = ByteArray(32) { 0x5A } + val voiceSigner = ConcordKeyDerivation.voiceSignerKey(channelSecret, channelId.chunkedToBytes(), epoch = 0) + val url = "https://broker.example" + ConcordBrokerToken.wellKnownPath(voiceSigner.publicKeyHex) + + val event = ConcordBrokerToken.buildAuthEvent(voiceSigner, url, createdAt = 1_700_000_000L) + assertEquals(ConcordBrokerToken.KIND, event.kind) + assertEquals(voiceSigner.publicKeyHex, event.pubKey) // the SFU room = voice key pubkey + assertTrue(event.verify()) + + val header = ConcordBrokerToken.authorizationHeader(event) + assertTrue(header.startsWith("Concord ")) + } + + private fun String.chunkedToBytes(): ByteArray = ByteArray(length / 2) { substring(it * 2, it * 2 + 2).toInt(16).toByte() } +} From 46a2652fc583f8f23af445fb27a154874a1e972a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:11:22 +0000 Subject: [PATCH 014/115] feat(concord): add direct invites (kind 3313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes CORD-05: for a known npub, deliver the CommunityInvite as a NIP-59 giftwrap instead of a public bundle — a kind-3313 rumor sealed (kind 13) to the recipient and wrapped (1059) with ["p", recipient] and a ["k","3313"] index tag so recipients can query pending invites without decrypting every giftwrap. Cannot be revoked (recipient holds the keys on arrival). Reuses SealedRumorEvent + the giftwrap primitives. Tests cover round-trip to the intended recipient (with p/k tags) and that strangers cannot open it. Green on :quartz:jvmTest. With this, the Quartz protocol layer covers CORD-01..07 end to end. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../cord05Invites/ConcordDirectInvite.kt | 89 +++++++++++++++++++ .../cord05Invites/ConcordDirectInviteTest.kt | 66 ++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt new file mode 100644 index 0000000000..0e18eabfa8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Direct invites (CORD-05): for a known npub, the invite skips the public bundle + * and is delivered as a standard NIP-59 giftwrap — a kind-3313 rumor carrying the + * [CommunityInvite], sealed (kind 13) to the recipient and wrapped (kind 1059) + * with `["p", recipient]` and a `["k", "3313"]` index tag so the recipient can + * query for pending invites without decrypting every giftwrap. + * + * It cannot be revoked — the recipient holds the keys the moment it lands. + */ +object ConcordDirectInvite { + const val KIND: Int = ConcordKinds.DIRECT_INVITE + const val TAG_P = "p" + const val TAG_K = "k" + + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) + + /** + * Builds a giftwrapped direct invite from [senderSigner] to [recipientPubKey]. + * Returns the kind-1059 wrap to publish to the recipient's inbox relays. + */ + suspend fun build( + senderSigner: NostrSigner, + recipientPubKey: HexKey, + invite: CommunityInvite, + createdAt: Long, + ): GiftWrapEvent { + val rumor = RumorAssembler.assembleRumor(senderSigner.pubKey, createdAt, KIND, emptyArray(), json(invite)) + val seal = SealedRumorEvent.create(rumor, recipientPubKey, senderSigner, createdAt = createdAt) + + // Wrap with a random ephemeral key, adding the ["k","3313"] index tag. + val wrapSigner = NostrSignerInternal(KeyPair()) + val content = wrapSigner.nip44Encrypt(seal.toJson(), recipientPubKey) + return wrapSigner.sign( + createdAt = createdAt, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf(TAG_P, recipientPubKey), arrayOf(TAG_K, KIND.toString())), + content = content, + ) + } + + /** + * Opens a direct-invite giftwrap addressed to [recipientSigner] and returns the + * [CommunityInvite], or null if it isn't a valid direct invite for this user. + * Callers should still [ConcordInviteBundle.validate] the result. + */ + suspend fun parse( + wrap: GiftWrapEvent, + recipientSigner: NostrSigner, + ): CommunityInvite? { + val seal = wrap.unwrapOrNull(recipientSigner) ?: return null + if (seal !is SealedRumorEvent) return null + val rumor = seal.unsealOrNull(recipientSigner) ?: return null + if (rumor.kind != KIND) return null + return ConcordJson.decodeOrNull(rumor.content) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt new file mode 100644 index 0000000000..396ffbad1b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInviteTest.kt @@ -0,0 +1,66 @@ +/* + * 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.quartz.concord.cord05Invites + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ConcordDirectInviteTest { + private val sender = NostrSignerInternal(KeyPair()) + private val recipient = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + + private val invite = + CommunityInvite( + communityId = "11".repeat(32), + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + communityRoot = "bb".repeat(32), + name = "Nostrichs", + ) + + @Test + fun directInviteRoundTripsToTheRecipient() = + runTest { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1_700_000_000L) + + // Wrap is a giftwrap tagged for the recipient and indexable by k=3313. + assertEquals(recipient.pubKey, wrap.tags.first { it[0] == "p" }[1]) + assertEquals("3313", wrap.tags.first { it[0] == "k" }[1]) + + val parsed = ConcordDirectInvite.parse(wrap, recipient) + assertNotNull(parsed) + assertEquals("Nostrichs", parsed.name) + assertEquals("11".repeat(32), parsed.communityId) + } + + @Test + fun strangersCannotOpenIt() = + runTest { + val wrap = ConcordDirectInvite.build(sender, recipient.pubKey, invite, createdAt = 1L) + assertNull(ConcordDirectInvite.parse(wrap, stranger)) + } +} From 587387ba96cd4fc1095c4724b94df33e79d01028 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:34:15 +0000 Subject: [PATCH 015/115] feat(concord): add commons ConcordActions (CLI-safe business layer) Pure builders + relay-filter assembly + folding for Concord, usable from amy CLI and the Android app (like DmActions, it never touches the network): - plane key derivation (controlPlane/publicChannel) - relay filters (planeFilter, bundleFilter, directInvitesFilter) - createCommunity, foldCommunity (open control wraps -> editions -> live state) - buildChannelMessage + channelMessages (open, bind-check, order oldest-first) - invite helpers: inviteFor, mintInviteLink, parseInviteLink, openBundle (decrypt+validate), controlPlaneFor Test covers the create -> fold -> send -> read round-trip and the mint -> parse -> open -> read invite flow. Green on :commons:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 186 ++++++++++++++++++ .../commons/actions/ConcordActionsTest.kt | 82 ++++++++ 2 files changed, 268 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt new file mode 100644 index 0000000000..188cb3f092 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -0,0 +1,186 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** One decrypted, verified Concord channel message projected for display. */ +data class ConcordChatMessage( + val id: HexKey, + val author: HexKey, + val content: String, + val createdAt: Long, + val channelId: HexKey, + val epoch: Long, +) + +/** + * Concord community verbs — pure builders, plane-key derivation, relay-filter + * assembly, and event folding usable from amy CLI, the Android app, and any other + * non-UI consumer. + * + * Like [DmActions], this object never touches the network: create/send builders + * return events to publish, the read side takes already-fetched wraps and folds + * them. The caller (amy `Context`, an Android ViewModel) owns publish/drain and + * persistence of the community's secrets. + */ +object ConcordActions { + // ---- plane key derivation ------------------------------------------------- + + fun controlPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + + fun publicChannel( + communityRoot: ByteArray, + channelId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + // ---- relay filters (what to REQ) ----------------------------------------- + + /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ + fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = listOf(planePubKeyHex)) + + /** The public invite bundle for a link signer. */ + fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.INVITE_BUNDLE), authors = listOf(linkSignerPubKeyHex)) + + /** Pending direct invites addressed to the given member (indexed by k=3313). */ + fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordKinds.DIRECT_INVITE.toString()))) + + // ---- community lifecycle -------------------------------------------------- + + /** Creates a community and its genesis editions (see [ConcordCommunityFactory]). */ + suspend fun createCommunity( + ownerSigner: NostrSigner, + name: String, + createdAt: Long, + description: String? = null, + relays: List = emptyList(), + ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays) + + /** Opens the control-plane [wraps] and folds them into the live community state. */ + fun foldCommunity( + wraps: List, + controlPlane: GroupKey, + ownerPubKey: HexKey, + ): ConcordCommunityState { + val editions = + wraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } + } + return ConcordCommunityState.fold(editions, ownerPubKey) + } + + // ---- channel chat --------------------------------------------------------- + + /** Builds an encrypted-seal channel message wrap to publish on the [channel] plane. */ + suspend fun buildChannelMessage( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + text: String, + createdAt: Long, + ): Event { + val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, 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). + */ + fun channelMessages( + wraps: List, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): List = + wraps + .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } + .filter { it.kind == ConcordKinds.MESSAGE && ChannelChat.isBoundTo(it, channelId, epoch) } + .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } + .sortedWith(compareBy({ it.createdAt }, { it.id })) + + // ---- invites -------------------------------------------------------------- + + /** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */ + fun inviteFor( + communityIdHex: HexKey, + ownerPubKey: HexKey, + ownerSaltHex: HexKey, + communityRootHex: HexKey, + rootEpoch: Long, + name: String, + relays: List, + ): CommunityInvite = + CommunityInvite( + communityId = communityIdHex, + owner = ownerPubKey, + ownerSalt = ownerSaltHex, + communityRoot = communityRootHex, + rootEpoch = rootEpoch, + relays = relays, + name = name, + ) + + /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ + fun mintInviteLink( + base: String, + invite: CommunityInvite, + createdAt: Long, + relays: List? = null, + ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + + /** Parses a shareable invite URL into its pointer + private fragment. */ + fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url) + + /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ + fun openBundle( + bundleEvent: Event, + token: ByteArray, + ): CommunityInvite? = ConcordInviteBundle.parse(bundleEvent, token)?.takeIf { ConcordInviteBundle.validate(it) } + + /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ + fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt new file mode 100644 index 0000000000..173634d2db --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -0,0 +1,82 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConcordActionsTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun createFoldSendReadRoundTrip() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example")) + + // Fold genesis -> live state + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + assertEquals("Test Server", state.metadata?.name) + assertTrue(state.channels.containsKey(community.generalChannelIdHex)) + + // Send + read a channel message + val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val wrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "hello world", createdAt = 2L) + val msgs = ConcordActions.channelMessages(listOf(wrap), channel, community.generalChannelIdHex, community.rootEpoch) + assertEquals(1, msgs.size) + assertEquals("hello world", msgs[0].content) + assertEquals(owner.pubKey, msgs[0].author) + } + + @Test + fun inviteMintParseAndOpen() = + runTest { + val community = ConcordActions.createCommunity(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val invite = + ConcordActions.inviteFor( + communityIdHex = community.communityIdHex, + ownerPubKey = community.ownerPubKey, + ownerSaltHex = community.ownerSalt.toHex(), + communityRootHex = community.communityRoot.toHex(), + rootEpoch = community.rootEpoch, + name = "Nostrichs", + relays = listOf("wss://r.example"), + ) + val minted = ConcordActions.mintInviteLink("https://vector.chat", invite, createdAt = 1L) + + val parsed = ConcordActions.parseInviteLink(minted.url) + assertNotNull(parsed) + val opened = ConcordActions.openBundle(minted.bundleEvent, parsed.fragment.token) + assertNotNull(opened) + assertEquals(community.communityIdHex, opened.communityId) + + // The joiner can derive the control plane and read the genesis. + val controlPlane = ConcordActions.controlPlaneFor(opened) + val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner) + assertEquals("Nostrichs", state.metadata?.name) + } + + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } +} From dd9623a16ed93acc556022c0486b101dc69ab825 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:43:50 +0000 Subject: [PATCH 016/115] feat(concord): add `amy concord` CLI (create/join/send/read/invite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Concord stack through the CLI, thin over commons ConcordActions + Context: - ConcordStore (~/.amy//concord.json, 0600): joined communities + their secrets for re-derivation across runs - concord create — mint community + publish genesis, save locally - concord list — list joined communities - concord channels — drain + fold the Control Plane, list channels - concord send — post an encrypted kind-9 message to a channel - concord read — drain + decrypt a channel's messages (oldest-first) - concord invite — mint + publish a shareable invite link (bundle 33301) - concord join URL — fetch + decrypt the bundle with the fragment token, save Verified end-to-end against a local `amy serve` (geode) relay: Alice creates a community and mints an invite; Bob joins from the URL alone, both post to #general, and both read the identical decrypted message list. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../com/vitorpamplona/amethyst/cli/Config.kt | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 11 + .../cli/commands/ConcordChannelCommands.kt | 134 +++++++++++++ .../amethyst/cli/commands/ConcordCommands.kt | 189 ++++++++++++++++++ .../amethyst/cli/stores/ConcordStore.kt | 73 +++++++ 5 files changed, 408 insertions(+) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 48f19276c4..4454e0a532 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -230,6 +230,7 @@ class DataDir( val stateFile = File(root, "state.json") val aliasesFile = File(root, "aliases.json") val cashuFile = File(root, "cashu.json") + val concordFile = File(root, "concord.json") val marmotDir = File(root, "marmot") val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index c9aa8c2461..78793c9772 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.cli.commands.AdminCommand import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands import com.vitorpamplona.amethyst.cli.commands.BunkerCommand +import com.vitorpamplona.amethyst.cli.commands.ConcordCommands import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.DebitCommands @@ -289,6 +290,7 @@ private suspend fun dispatch(argv: Array): Int { "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) "wot" -> WotCommand.dispatch(dataDir, tail) + "concord" -> ConcordCommands.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -702,6 +704,15 @@ private fun printUsage() { | | marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive) | + | concord create --name NAME [--about T] [--relays wss://a,wss://b] + | create an encrypted Concord Channel community + | concord list list joined Concord communities + | concord channels COMMUNITY list a community's channels + | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) + | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages + | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link + | concord join URL redeem an invite link and save the community + | |Local event store (shared, under `/shared/`): | Backend selected by AMY_STORE: sqlite (default; `shared/events.db`) | or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt new file mode 100644 index 0000000000..b45b222a82 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -0,0 +1,134 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils + +/** `amy concord channels|send|read` — the per-channel chat verbs. */ +object ConcordChannelCommands { + private val HEX64 = Regex("[0-9a-fA-F]{64}") + + suspend fun channels( + dataDir: DataDir, + rest: Array, + ): Int { + val handle = Args(rest).positional(0, "community") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val state = foldState(ctx, sc) + Output.emit( + mapOf( + "name" to state.metadata?.name, + "channels" to + state.channels.values.map { + mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private) + }, + ), + ) + return 0 + } + } + + suspend fun send( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val text = args.positional(2, "text") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("event_id" to wrap.id, "channel" to channelId, "published_to" to acked.map { it.url })) + return 0 + } + } + + suspend fun read( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val limit = args.intFlag("limit", 50) + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) + val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }).map { it.second } + val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit) + Output.emit( + mapOf( + "channel" to channelId, + "count" to msgs.size, + "messages" to msgs.map { mapOf("id" to it.id, "author" to it.author, "content" to it.content, "created_at" to it.createdAt) }, + ), + ) + return 0 + } + } + + /** Drain the control plane and fold it into the current community state. */ + private suspend fun foldState( + ctx: Context, + sc: StoredCommunity, + ): ConcordCommunityState { + val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }).map { it.second } + return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) + } + + /** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */ + private suspend fun resolve( + ctx: Context, + sc: StoredCommunity, + ref: String, + ): String? { + if (ref == "general" && sc.generalChannelId.isNotBlank()) return sc.generalChannelId + if (HEX64.matches(ref)) return ref + val state = foldState(ctx, sc) + return state.channels.values + .firstOrNull { it.definition.name.equals(ref, ignoreCase = true) } + ?.channelIdHex + ?: state.channels.values + .firstOrNull { it.channelIdHex.startsWith(ref) } + ?.channelIdHex + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt new file mode 100644 index 0000000000..568593e3ce --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -0,0 +1,189 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy concord …` — create, join, and drive Concord Channels (encrypted, + * serverless communities). Thin assembly over [ConcordActions] (commons) and + * [Context]; secrets persist in `~/.amy//concord.json`. + */ +object ConcordCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "concord", + tail, + "concord ", + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "list" to { rest -> list(dataDir, rest) }, + "channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) }, + "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, + "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, + "invite" to { rest -> invite(dataDir, rest) }, + "join" to { rest -> join(dataDir, rest) }, + ), + ) + + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val name = args.requireFlag("name") + val about = args.flag("about") + val relayArg = parseRelays(args.flag("relays")) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = relayArg.ifEmpty { ctx.outboxRelays().map { it.url } } + val community = ConcordActions.createCommunity(ctx.signer, name, TimeUtils.now(), about, relays) + + val publishTo = normalize(relays).ifEmpty { ctx.outboxRelays() } + val acked = mutableSetOf() + for (wrap in community.genesisWraps) acked += ctx.publish(wrap, publishTo).filterValues { it }.keys + + ConcordStore(dataDir.concordFile).upsert( + StoredCommunity( + name = name, + communityId = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + generalChannelId = community.generalChannelIdHex, + relays = relays, + ), + ) + + Output.emit( + mapOf( + "community_id" to community.communityIdHex, + "name" to name, + "general_channel_id" to community.generalChannelIdHex, + "published_to" to acked.map { it.url }, + ), + ) + return 0 + } + } + + private fun list( + dataDir: DataDir, + @Suppress("UNUSED_PARAMETER") rest: Array, + ): Int { + val communities = + ConcordStore(dataDir.concordFile).load().map { + mapOf("name" to it.name, "community_id" to it.communityId, "owner" to it.owner, "relays" to it.relays) + } + Output.emit(mapOf("communities" to communities)) + return 0 + } + + private suspend fun invite( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val base = args.flag("base", "https://vector.chat")!! + + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) + val acked = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)).filterValues { it }.keys + + Output.emit( + mapOf( + "url" to minted.url, + "bundle_event_id" to minted.bundleEvent.id, + "link_signer" to minted.linkSignerPubKey, + "published_to" to acked.map { it.url }, + ), + ) + return 0 + } + } + + private suspend fun join( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val url = args.positional(0, "url") + val parsed = ConcordActions.parseInviteLink(url) ?: return Output.error("bad_args", "not a valid invite link").let { 2 } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val relays = (normalize(parsed.fragment.relays) + ctx.bootstrapRelays()) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } + val bundle = + wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + ?: return Output.error("not_found", "no valid bundle for this link").let { 1 } + + ConcordStore(dataDir.concordFile).upsert( + StoredCommunity( + name = bundle.name, + communityId = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + ), + ) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) + return 0 + } + } + + // ---- shared helpers (used by ConcordChannelCommands too) ------------------ + + fun parseRelays(csv: String?): List = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() + + fun normalize(urls: List): Set = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + suspend fun relaysFor( + ctx: Context, + sc: StoredCommunity, + ): Set = normalize(sc.relays).ifEmpty { ctx.outboxRelays() } + + fun notFound(handle: String): Int { + Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") + return 1 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt new file mode 100644 index 0000000000..95cfb90f22 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -0,0 +1,73 @@ +/* + * 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.cli.stores + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.SecureFileIO +import java.io.File + +/** + * A joined/created Concord community persisted locally so later `send`/`read`/ + * `channels` runs can re-derive its planes. Holds the community's secrets, so the + * file is written 0600 via [SecureFileIO]. + */ +data class StoredCommunity( + val name: String = "", + val communityId: String = "", + val owner: String = "", + val ownerSalt: String = "", + val root: String = "", + val rootEpoch: Long = 0, + val generalChannelId: String = "", + val relays: List = emptyList(), +) + +/** + * File-backed list of the account's Concord communities at `~/.amy// + * concord.json`. Reloaded per run (no in-process cache), matching Amy's + * stateless-per-invocation model. + */ +class ConcordStore( + private val file: File, +) { + fun load(): List = + if (file.exists()) { + runCatching { Output.mapper.readValue>(file.readText()) }.getOrDefault(emptyList()) + } else { + emptyList() + } + + fun save(list: List) = SecureFileIO.writeTextAtomic(file, Output.mapper.writeValueAsString(list)) + + /** Insert or replace by community id, keyed on the self-certifying id. */ + fun upsert(community: StoredCommunity) { + val next = load().filterNot { it.communityId == community.communityId } + community + save(next) + } + + /** Resolve a user-supplied handle: exact name, exact id, or a unique id/name prefix. */ + fun find(handle: String): StoredCommunity? { + val all = load() + return all.firstOrNull { it.name == handle || it.communityId == handle } + ?: all.singleOrNull { it.communityId.startsWith(handle) || it.name.startsWith(handle) } + } +} From b816842e5da87744ddb80efa0abb3292fdfbd7a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:17:28 +0000 Subject: [PATCH 017/115] docs(concord): add mobile integration plan mirroring NIP-29 relay groups Blueprint for the Android app layer, cloning the just-merged NIP-29 relay-groups touch points with Concord equivalents: commons ConcordChannel + kind-13302 ConcordChannelListState, Account/LocalCache wiring, the 6-way Messages-inbox concatenation + synthetic server row (chip opens the channel), the reused NIP-28 ChannelView chat screens, nav routes, a GitRepositories-style discovery feed, and notification routing + on-plane zaps/likes. Documents the one structural difference from NIP-29: Concord communities are E2EE (plane-pubkey addressing, no public relay-signed metadata), so discovery surfaces public invite links rather than browsable metadata. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../2026-07-10-concord-mobile-integration.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 amethyst/plans/2026-07-10-concord-mobile-integration.md diff --git a/amethyst/plans/2026-07-10-concord-mobile-integration.md b/amethyst/plans/2026-07-10-concord-mobile-integration.md new file mode 100644 index 0000000000..b5d713d46d --- /dev/null +++ b/amethyst/plans/2026-07-10-concord-mobile-integration.md @@ -0,0 +1,168 @@ +# Concord — Mobile Integration Plan (mirroring NIP-29 Relay Groups) + +## Context + +The Concord protocol engine is complete in `quartz/…/concord/` (CORD-01…07, +~65 tests) and driven end-to-end by the `amy concord` CLI over a commons +`ConcordActions` layer. This plan covers the **Android app integration**, and it +deliberately **mirrors the just-merged NIP-29 relay-groups feature** — that work +used Soapbox's Armada as a study base and established the exact Amethyst touch +points a group-chat protocol should plug into. Wherever possible we clone the +NIP-29 file structure with Concord equivalents rather than inventing parallels. + +Naming: user-facing = **"Concord Channels"** (Amethyst reserves "community" for +NIP-72). Protocol-internal code keeps the spec term `community`. + +## The one structural difference from NIP-29 + +NIP-29 group metadata (kind 39000) is **relay-signed and public**, so groups are +browsable. Concord communities are **end-to-end encrypted**: the only public +artifact is the addressable kind-33301 invite **bundle**, whose content is +token-gated. Consequences for the mirror: + +- **Addressing** is by *derived stream pubkey* (`group_key.pk` per plane/epoch), + not `(hostRelay, groupId)`. A Concord channel lives at its plane address and + may be mirrored on several relays (the community's relay set), not pinned to + one host. So `ConcordChannel.relays()` = the community relay set. +- **Discovery** cannot preview E2EE content. The discovery feed surfaces **public + invite links** (kind-33301 bundles + links shared in notes), filtered by + author/hashtag — the entry action is *redeem a link*, not *browse contents*. + This is a genuinely thinner surface than NIP-29; documented, not a bug. +- **Membership = key possession**, verified locally from the folded Control Plane + + banlist (already implemented), not from relay-signed 39001/39002. + +## Layering (same as NIP-29) + +- `quartz/…/concord/` — protocol (done) +- `commons/…/model/concord/` — `ConcordChannel`, `ConcordChannelListState`, + membership/view-mode enums, discovery constraint (platform-agnostic) +- `amethyst/…/chats/publicChannels/concord/` — screens, feed filters, datasource + subassemblers, navigation +- `commons/…/actions/ConcordActions.kt` — builders/filters/folding (done) +- `cli/…/commands/Concord*Commands.kt` — verbs (done; already matches the + `RelayGroupCommands` route+verb-map pattern) + +## Mirror map (NIP-29 file → Concord equivalent) + +### commons state +- `model/nip29RelayGroups/RelayGroupChannel.kt` → **`model/concord/ConcordChannel.kt`** + — a `Channel` subclass keyed by a `ConcordChannelId(communityId, channelId)`, + holding the folded `ConcordCommunityState` + this channel's messages StateFlow, + `relays()` = community relay set, `membershipOf()` from the authority resolver, + `placeholderNote()`. +- `RelayGroupListState.kt` → **`model/concord/ConcordChannelListState.kt`** — + backed by the **kind-13302** joined-communities list (already in quartz: + `ConcordCommunityList`). Exposes `liveCommunities: StateFlow>` and + `liveServers: StateFlow>`. `join(community)`/`leave` do + read-modify-write of the 13302 event. Mirrors `EphemeralChatListState`. +- `RelayGroupMembership.kt` → **`ConcordMembership.kt`** (OWNER/ADMIN/MEMBER/BANNED/ + NONE) derived from `AuthorityResolver` (rank + banlist). +- `RelayGroupViewMode.kt` → **`ConcordViewMode.kt`** (INLINE/GROUPED). +- `model/nip29RelayGroups/GroupDiscoveryConstraint.kt` → **`ConcordDiscoveryConstraint.kt`** + (AllPublic / ByPeople / ByHashtags) matching against a public invite bundle. + +### Account wiring (`amethyst/…/model/Account.kt`) +Add right after the `relayGroupList` lines (~382): a +`ConcordChannelListState(signer, cache, decryptionCache, scope, settings)` field ++ its decryption cache. Action methods next to `joinRelayGroup` (~1472): +`createConcordCommunity`, `joinConcordFromLink`, `postConcordMessage`, +`createConcordInvite`, `banConcordMember`, `follow/unfollow(ConcordChannel)` → +delegate to `ConcordChannelListState`. Writes go through the community relay set. +Add `concordViewMode` to `AccountSettings.kt`. + +### LocalCache (`amethyst/…/model/LocalCache.kt`) +Add a `LargeCache` index + `getOrCreateConcordChannel`, +and route inbound kind-1059 wraps on known plane addresses into the fold (decrypt +→ edition/message). Mirrors `getOrCreateRelayGroupChannel`. + +### Messages inbox integration (THE key mirror) +- `chats/rooms/dal/ChatroomListKnownFeedFilter.kt` + `ChatroomListNewFeedFilter.kt` + — extend the 5-way `feed()` concatenation to **6-way**: add a `concordChannels` + block reading `account.concordChannelList.liveCommunities`, branching on + `concordViewMode` (INLINE = one row per channel via + `LocalCache.getOrCreateConcordChannel(...).newestChatNote() ?: placeholderNote()`; + GROUPED = one synthetic `ConcordServerRoomNote(communityId, newest)` per + community). Update `applyFilter`/`updateListWith` with a + `filterRelevantConcordMessages(...)` keyed by `concordRowKey()`. +- `chats/rooms/dal/RelayGroupServerRoomNote.kt` → **`ConcordServerRoomNote.kt`** — + synthetic event-less Note collapsing a community's channels into one inbox row. +- `chats/rooms/ChatroomHeaderCompose.kt` — add `rendersWithoutEvent` branches for + `ConcordServerRoomNote` and channel placeholders; `ConcordServerRoomCompose` → + `Route.ConcordServer(communityId)`; `ConcordRoomCompose` (chip = community name) + → `routeFor(channel)`. **This is where the "chip opens the Concord Channel" + requirement lands.** + +### Screens (`amethyst/…/chats/publicChannels/concord/`, mirror `relayGroup/`) +- `ConcordServerList.kt` (community rows) · `ConcordChannelListScreen.kt(communityId)` + (a community's channels, from the folded Control Plane) · + `ConcordChatScreen.kt(communityId, channelId, …)` (top-level route target) · + `ConcordChannelView.kt` (reuse the NIP-28 `ChannelFeedViewModel`/`ChannelView` + stack via the `ConcordChannel: Channel` subclass) · `ConcordMembersScreen.kt` · + `ConcordMetadataScreen.kt`/`ViewModel.kt` (create/edit) · `ConcordTopBar.kt` + (name + role badge + Members/Edit/Invite/Ban/Leave menu) · `LoadConcordChannel.kt`. +- Compose composer gated on `membershipOf(me).isMember()`; else a "redeem an + invite to post" notice. + +### Discovery feed (GitRepositories-style triad; thinner than NIP-29) +- `concord/dal/ConcordDiscoveryFeedFilter.kt` (`AdditiveFeedFilter` over + public kind-33301 bundles; "My Communities" branch = the 13302 list) + + `concord/dal/ConcordDiscoveryConstraint.kt` bridge + + `concord/datasource/subassemblies/FilterConcordBundlesBy{Authors,Follows,Hashtag}.kt`. + `ConcordDiscoveryScreen.kt` = `DisappearingScaffold` + `FeedFilterSpinner` + + `RenderFeedContentState` with `ConcordDiscoveryCard` (name + Join button). FAB → + `ConcordBrowse`/redeem-link. + +### Navigation (`ui/navigation/routes/Routes.kt` + `AppNavigation.kt`) +`@Serializable` routes: `Concord`(communityId, channelId, +draftId?/inviteToken?), +`ConcordServer`(communityId), `ConcordMembers`, `ConcordCreate`, `ConcordEdit`, +`Concords`(object, bottom-nav → discovery), `ConcordBrowse`. `RouteMaker.routeFor(ConcordChannel)` ++ deep-link: an invite URL/`nostr:`-embedded link → `Route.Concord(..., inviteToken=…)`, +auto-redeeming on open (mirror NIP-29's inviteCode auto-join). Wire through +`BouncingIntentNav.kt`. + +### Invite/redeem UI + linkification +- `InviteConcordDialog.kt` (moderator: mint + share link via `ConcordActions.mintInviteLink`) + · `JoinConcordDialog.kt` (paste a link → redeem) · `ui/components/ConcordInviteCard.kt` + (render a link as a preview card; tap → `Route.Concord(inviteToken)`) · + `ui/components/ClickableConcordInviteLink.kt` (inline linkify shared invite URLs). + +### Notifications (your explicit ask) +Route a Concord message notification click to the **channel chat**, not the feed: +in the notification builder + `BouncingIntentNav`, map a Concord message +notification to `Route.Concord(communityId, channelId)`. Mirror how NIP-29 +group notifications resolve via `routeFor`. + +### Zaps & likes +Because `ConcordChannel` extends `Channel` and messages render through the shared +`ChannelView`, reactions (kind 7) and zaps attach through the existing chat +reaction/zap path — but they must be **wrapped on the channel plane** (kind-7/9735 +rumors sealed like messages, bound to channel+epoch), not published in the clear. +Add `ConcordActions.buildReaction`/`buildZapRequest` that wrap on the plane, and +point the shared reaction/zap affordances at them for Concord notes. + +## Build order (each a tested, shippable slice) +1. **commons foundation** — `ConcordChannel`, `ConcordChannelListState` (13302), + membership/view-mode enums; unit tests. Wire into `Account.kt` + `AccountSettings`. +2. **LocalCache index** + inbound wrap folding. +3. **Messages inbox** 6-way concat + `ConcordServerRoomNote` + header render/nav + (delivers the chip-opens-channel behavior). +4. **Chat screens** (reuse NIP-28 `ChannelView`) + nav routes + create/invite/join. +5. **Discovery feed** triad (public invite bundles). +6. **Notifications routing + zaps/likes on-plane.** + +## Verification +- commons: `:commons:jvmTest` unit tests for `ConcordChannelListState` (13302 + round-trip/merge) and `ConcordChannel` folding, mirroring + `RelayGroupListDecryptionTest`/`RelayGroupChannelTest`. +- Android: `:amethyst:installDebug`; create a community, see it in Messages with a + chip, tap → channel opens, send/receive between two emulators, redeem an invite + link deep-link, verify a notification click opens the chat. Cross-check against + `amy concord` (same relay) for wire interop, and against Armada for protocol + interop (`Nip29ArmadaInteropTest` is the precedent). + +## Gotchas carried from the NIP-29 study +- Membership has two independent layers (Concord authority vs NIP-43 relay + membership); we only implement Concord authority. +- Cache-as-floor + optimistic local signing for snappy UX. +- E2EE means no server-side moderation and no metadata preview — surface state + from the local fold only. From 4672b60c9d75bf1c3a95f4ac0da8cf5300dbe227 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:19:36 +0000 Subject: [PATCH 018/115] feat(concord): add mobile foundation enums (membership + view mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the Android integration, mirroring NIP-29's commons model: - ConcordMembership (OWNER/ADMIN/MEMBER/BANNED/NONE) derived from the folded owner-rooted AuthorityResolver + banlist — membership is key possession, roles layer moderation power, the banlist removes standing; with isMember/canModerate - ConcordViewMode (INLINE/GROUPED) for how joined channels surface in Messages Test classifies owner/admin/member/banned/stranger from a folded control plane. Green on :commons:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../model/concord/ConcordMembership.kt | 87 +++++++++++++++++++ .../commons/model/concord/ConcordViewMode.kt | 38 ++++++++ .../model/concord/ConcordMembershipTest.kt | 73 ++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt new file mode 100644 index 0000000000..ca9337671d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembership.kt @@ -0,0 +1,87 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A user's standing in a Concord community, derived from the locally-folded + * Control Plane (the owner-rooted [AuthorityResolver] + banlist). + * + * Unlike NIP-29 (where the relay's signed roster is the truth), Concord + * membership is **key possession**: anyone holding the community key is at least a + * [MEMBER]. Roles layer moderation power on top ([ADMIN]/[OWNER]); the banlist + * removes standing ([BANNED]). [NONE] is only for a user we can't place at all. + */ +enum class ConcordMembership { + /** The community founder — supreme, unremovable. */ + OWNER, + + /** Holds at least one moderation permission (manage roles/channels, kick, ban…). */ + ADMIN, + + /** Holds the community key, no elevated role. */ + MEMBER, + + /** On the community banlist — dropped everywhere. */ + BANNED, + + /** Not placeable in this community. */ + NONE, + ; + + /** True when the user is an active participant (holds the key and isn't banned). */ + fun isMember(): Boolean = this == OWNER || this == ADMIN || this == MEMBER + + /** True when the user may take moderation actions. */ + fun canModerate(): Boolean = this == OWNER || this == ADMIN + + companion object { + private val MOD_BITS = + intArrayOf( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ) + + /** + * Classifies [pubKey] against a folded [authority]. [holdsKey] tells us the + * user is a member of this community locally (we joined it / hold its key), + * which distinguishes a plain [MEMBER] from [NONE]. + */ + fun of( + authority: AuthorityResolver, + pubKey: HexKey, + holdsKey: Boolean = true, + ): ConcordMembership { + if (authority.isBanned(pubKey)) return BANNED + if (authority.isOwner(pubKey)) return OWNER + val perms = authority.effectivePermissions(pubKey) + if (MOD_BITS.any { perms.has(it) }) return ADMIN + return if (holdsKey) MEMBER else NONE + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt new file mode 100644 index 0000000000..2974e8361d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt @@ -0,0 +1,38 @@ +/* + * 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.commons.model.concord + +/** + * How joined Concord Channels surface in the Messages inbox (mirrors + * `RelayGroupViewMode`). + */ +enum class ConcordViewMode { + /** One inbox row per channel across all joined communities. */ + INLINE, + + /** One inbox row per community, collapsing its channels behind a drill-down. */ + GROUPED, + ; + + companion object { + val DEFAULT = INLINE + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt new file mode 100644 index 0000000000..770e7ed98e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt @@ -0,0 +1,73 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConcordMembershipTest { + private val owner = "0f".repeat(32) + private val admin = "a1".repeat(32) + private val member = "b2".repeat(32) + private val banned = "c3".repeat(32) + private val stranger = "d4".repeat(32) + private val adminRole = "11".repeat(32) + + private fun ed( + kind: ControlEntityKind, + eid: String, + content: String, + author: String = owner, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + + private val authority = + AuthorityResolver.resolve( + listOf( + ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES + ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""), + ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""), + ), + owner, + ) + + @Test + fun classifiesStandingFromAuthority() { + assertEquals(ConcordMembership.OWNER, ConcordMembership.of(authority, owner)) + assertEquals(ConcordMembership.ADMIN, ConcordMembership.of(authority, admin)) + assertEquals(ConcordMembership.MEMBER, ConcordMembership.of(authority, member)) + assertEquals(ConcordMembership.BANNED, ConcordMembership.of(authority, banned)) + // A user we don't hold the key for reads as NONE. + assertEquals(ConcordMembership.NONE, ConcordMembership.of(authority, stranger, holdsKey = false)) + } + + @Test + fun capabilityHelpers() { + assertEquals(true, ConcordMembership.OWNER.canModerate()) + assertEquals(true, ConcordMembership.ADMIN.canModerate()) + assertEquals(false, ConcordMembership.MEMBER.canModerate()) + assertEquals(true, ConcordMembership.MEMBER.isMember()) + assertEquals(false, ConcordMembership.BANNED.isMember()) + } +} From 0306633eaba89a6d3b07aa3c746978543d8072d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:23:29 +0000 Subject: [PATCH 019/115] docs(concord): document per-account persistence + subscription model Concord sits between NIP-17 and NIP-28/29: NIP-28/29-style author-addressed plane subscriptions (never #p=me, since the wrap p-tag is ephemeral) + NIP-17 shared-key E2EE + a kind-13302 self-encrypted membership list that also carries the community secrets. Home base = ConcordChannelListState over 13302 (mirrors RelayGroupListState/EphemeralChatListState, but entries hold keys); LocalCache projects live ConcordChannels; a per-plane author-subscription assembler keeps them fresh. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../2026-07-10-concord-mobile-integration.md | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/amethyst/plans/2026-07-10-concord-mobile-integration.md b/amethyst/plans/2026-07-10-concord-mobile-integration.md index b5d713d46d..6dbc2ece71 100644 --- a/amethyst/plans/2026-07-10-concord-mobile-integration.md +++ b/amethyst/plans/2026-07-10-concord-mobile-integration.md @@ -31,6 +31,46 @@ token-gated. Consequences for the mirror: - **Membership = key possession**, verified locally from the folded Control Plane + banlist (already implemented), not from relay-signed 39001/39002. +## Per-account persistence & subscription model (Concord is between NIP-17 and NIP-28/29) + +Separate **addressing** from **encryption/membership** and Concord's place is clear: + +| Concern | NIP-28 | NIP-29 | NIP-17 | **Concord** | +|---|---|---|---|---| +| Find messages by | channel id | `(relay, h)` | `#p = me` | **`authors=[derived plane pk]`** | +| Content | public | public | E2EE to you | **E2EE to a shared key** | +| Decrypt with | — | — | your key | **per-channel derived conv key** | +| Membership | open | relay roster | key possession | **key possession** | +| "My rooms" home | follow list | kind-10009 | chatroom set | **kind-13302 (carries secrets)** | + +The decisive point: a Concord wrap's `p` tag is **ephemeral**, so you can never +find messages with `#p = me` (the NIP-17 model). You subscribe **by author = the +derived plane pubkey** (NIP-28/29 addressing), a query only a secret-holder can +form, and decrypt with the shared plane key (NIP-17 E2EE). + +**Home base = kind-13302 `ConcordCommunityList`** (built in quartz): NIP-44 +self-encrypted, replaceable, relay-synced. Unlike NIP-17 (only secret is your +identity key) or NIP-29 (public group tags), **each entry carries the community +secrets** (`community_root`, salt, epoch, private-channel keys). Same trust model +as NIP-17's recoverable giftwrapped history: a leaked nsec exposes them, nothing +worse. `ConcordChannelListState` wraps 13302 exactly like `RelayGroupListState` +wraps 10009 / `EphemeralChatListState` wraps its list — **same wiring, entries +hold keys.** + +**In-memory projection (LocalCache):** `ConcordChannel` keyed by +`(communityId, channelId)`, holding the folded Control-Plane state + decrypted +messages — recomputed from events, never persisted as identity (the NIP-28/29 +half). + +**Subscription = per-plane author REQ, fanned out from the joined list** — not a +single `#p=me` catch-all. `ConcordMyChannelsFilterAssembler` (mirrors NIP-29's +`RelayGroupMyJoinedGroupsFilterAssembler`) walks `account.concordChannelList`, +derives each community's control-plane + channel-plane addresses, and issues +`{kinds:[1059], authors:[planePk]}` per plane across the community's relays. + +**Secrets at rest:** relay copy is self-NIP-44-encrypted (13302); the on-device +mirror can be wrapped with `commons/keystorage`. + ## Layering (same as NIP-29) - `quartz/…/concord/` — protocol (done) From 88732d507ca54457749f6d29022b7f4fc1dfdb1f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 17:36:49 +0000 Subject: [PATCH 020/115] feat(concord): make joined-list a registered Event (13302) for cache/account use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the kind-13302 joined-communities list to a proper Event subclass so the Android LocalCache/Account layer can observe it the way RelayGroupListState observes SimpleGroupListEvent (10009): - ConcordCommunityListEvent : Event — createAddress (13302, pubkey, ""), a suspend create(signer, entries), and decrypt(signer); content stays NIP-44 self-encrypted and carries each community's secrets - register the kind in EventFactory so inbound events parse to the typed class - extract encode/decode JSON helpers in ConcordCommunityList (shared by both) Test: create -> the wire form hides the name, a JSON round-trip resolves to the typed class via EventFactory, decrypt recovers entries, and the replaceable address is (13302, pubkey, ""). Green on :quartz:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../cord02Community/ConcordCommunityList.kt | 17 +++- .../ConcordCommunityListEvent.kt | 79 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../ConcordCommunityListEventTest.kt | 70 ++++++++++++++++ 4 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 7629f174a9..98c09f7898 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -78,11 +78,21 @@ object ConcordCommunityList { entries: List, createdAt: Long, ): Event { - val json = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries) - val content = signer.nip44Encrypt(json, signer.pubKey) + val content = signer.nip44Encrypt(encode(entries), signer.pubKey) return signer.sign(createdAt, ConcordKinds.COMMUNITY_LIST, emptyArray(), content) } + /** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */ + fun encode(entries: List): String = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries) + + /** Parses the decrypted plaintext JSON back into entries, or empty on failure. */ + fun decode(json: String): List = + try { + ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json) + } catch (_: Exception) { + emptyList() + } + /** Decrypts and parses a kind-13302 list event with [signer], or empty on failure. */ suspend fun parse( event: Event, @@ -90,8 +100,7 @@ object ConcordCommunityList { ): List { if (event.kind != ConcordKinds.COMMUNITY_LIST) return emptyList() return try { - val json = signer.nip44Decrypt(event.content, signer.pubKey) - ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json) + decode(signer.nip44Decrypt(event.content, signer.pubKey)) } catch (_: Exception) { emptyList() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt new file mode 100644 index 0000000000..96949c81d9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -0,0 +1,79 @@ +/* + * 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.quartz.concord.cord02Community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The member's private, self-encrypted list of joined Concord communities (kind + * [ConcordKinds.COMMUNITY_LIST] = 13302, CORD-05). A replaceable event whose + * `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON + * — including each community's secrets (`community_root`, salt, epoch, + * private-channel keys), so a single event both syncs membership across devices + * and carries the keys needed to re-derive every plane. + * + * This is the Concord analog of NIP-29's kind-10009 `SimpleGroupListEvent` and + * NIP-17's chatroom home base; it is what the Account's `ConcordChannelListState` + * observes. Only the owner's key decrypts it, so relays store ciphertext. + */ +@Immutable +class ConcordCommunityListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ + suspend fun decrypt(signer: NostrSigner): List = + try { + ConcordCommunityList.decode(signer.nip44Decrypt(content, signer.pubKey)) + } catch (_: Exception) { + emptyList() + } + + companion object { + const val KIND = ConcordKinds.COMMUNITY_LIST + const val ALT = "Private list of joined Concord communities" + + /** The replaceable coordinate for a member's list: `(13302, pubkey, "")`. */ + fun createAddress(pubKey: HexKey) = Address(KIND, pubKey, "") + + /** Builds a signed, self-encrypted list event from [entries]. */ + suspend fun create( + signer: NostrSigner, + entries: List, + createdAt: Long = TimeUtils.now(), + ): ConcordCommunityListEvent { + val content = signer.nip44Encrypt(ConcordCommunityList.encode(entries), signer.pubKey) + return signer.sign(createdAt, KIND, emptyArray(), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index a1de1467cb..2efaa46950 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.utils +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -603,6 +604,7 @@ class EventFactory { RootSiteEvent.KIND -> RootSiteEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) + ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt new file mode 100644 index 0000000000..4270f1a469 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEventTest.kt @@ -0,0 +1,70 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ConcordCommunityListEventTest { + private val signer = NostrSignerInternal(KeyPair()) + + private val entry = + ConcordCommunityListEntry( + id = "11".repeat(32), + owner = "0f".repeat(32), + ownerSalt = "aa".repeat(32), + root = "bb".repeat(32), + rootEpoch = 0, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + ) + + @Test + fun eventFactoryReturnsTypedClassAndDecrypts() = + runTest { + val event = ConcordCommunityListEvent.create(signer, listOf(entry), createdAt = 1L) + assertEquals(ConcordCommunityListEvent.KIND, event.kind) + assertTrue(!event.content.contains("Nostrichs")) // encrypted on the wire + + // A round-trip through JSON parsing resolves to the typed class via EventFactory. + val reparsed = Event.fromJson(event.toJson()) + assertIs(reparsed) + + val entries = reparsed.decrypt(signer) + assertEquals(1, entries.size) + assertEquals("Nostrichs", entries[0].name) + assertEquals(listOf("wss://relay.example"), entries[0].relays) + } + + @Test + fun replaceableAddressIsKindPubkeyEmpty() { + val addr = ConcordCommunityListEvent.createAddress(signer.pubKey) + assertEquals(ConcordCommunityListEvent.KIND, addr.kind) + assertEquals(signer.pubKey, addr.pubKeyHex) + assertEquals("", addr.dTag) + } +} From 22e131e51c72261f7850c302e979165ee936a255 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 18:11:17 +0000 Subject: [PATCH 021/115] feat(concord): wire ConcordChannelListState into Account (kind-13302 home base) The per-account home base for Concord Channels, mirroring RelayGroupListState: - commons ConcordChannelListState observes the kind-13302 addressable note, exposes liveCommunities (joined secret-bearing entries) and liveServers (community ids), and follow/unfollow read-modify-write the self-encrypted list; a ConcordListRepository backs offline restore - AccountSettings implements ConcordListRepository (backupConcordList + concordList/updateConcordListTo) and gains a concordViewMode setting - Account instantiates concordChannelList and exposes joinConcordCommunity(entry) / leaveConcordCommunity(id), publishing the list via the account outbox Concord's "in-between" shows here: same wiring as the NIP-29 list, but entries carry the community secrets (decrypt yields root/salt/epoch/channel keys), so one self-encrypted event both syncs membership and re-derives every plane. Compiles across :commons and :amethyst. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 10 ++ .../amethyst/model/AccountSettings.kt | 19 +++ .../model/concord/ConcordChannelListState.kt | 136 ++++++++++++++++++ 3 files changed, 165 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b8f6eb22d3..31075260c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.LocalPreferences 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.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState @@ -125,6 +126,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -382,6 +384,8 @@ class Account( val relayGroupListDecryptionCache = RelayGroupListDecryptionCache(signer) val relayGroupList = RelayGroupListState(signer, cache, relayGroupListDecryptionCache, scope, settings) + val concordChannelList = ConcordChannelListState(signer, cache, scope, settings) + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) @@ -1469,6 +1473,12 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) + /** Add a joined Concord community (secret-bearing entry) to the private kind-13302 list. */ + suspend fun joinConcordCommunity(entry: ConcordCommunityListEntry) = sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) + + /** Drop a joined Concord community from the private kind-13302 list by its id. */ + suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 002cd04e70..da3824bcf5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm +import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository @@ -35,6 +37,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -247,6 +250,7 @@ class AccountSettings( var backupGeohashList: GeohashListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupRelayGroupList: SimpleGroupListEvent? = null, + var backupConcordList: ConcordCommunityListEvent? = null, var backupTrustProviderList: TrustProviderListEvent? = null, var backupCashuWallet: CashuWalletEvent? = null, var backupNutzapInfo: NutzapInfoEvent? = null, @@ -275,8 +279,10 @@ class AccountSettings( val callsEnabled: MutableStateFlow = MutableStateFlow(true), val defaultRelayAuthPolicy: MutableStateFlow = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST), val relayGroupViewMode: MutableStateFlow = MutableStateFlow(RelayGroupViewMode.DEFAULT), + val concordViewMode: MutableStateFlow = MutableStateFlow(ConcordViewMode.DEFAULT), ) : EphemeralChatRepository, RelayGroupRepository, + ConcordListRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) val syncedSettings: AccountSyncedSettings = AccountSyncedSettings(AccountSyncedSettingsInternal()) @@ -1252,6 +1258,19 @@ class AccountSettings( } } + override fun concordList() = backupConcordList + + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) { + // The joined list lives entirely in NIP-44-encrypted content (secrets), + // so an empty `tags` is NOT an empty list — guard only on null. + if (newConcordList == null) return + + if (backupConcordList?.id != newConcordList.id) { + backupConcordList = newConcordList + saveAccountSettings() + } + } + fun updateTrustProviderListTo(trustProviderList: TrustProviderListEvent?) { if (trustProviderList == null || trustProviderList.tags.isEmpty()) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt new file mode 100644 index 0000000000..7a3e135fb2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -0,0 +1,136 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch + +/** Persistence hook for the last-known kind 13302 event (offline backup). */ +interface ConcordListRepository { + fun concordList(): ConcordCommunityListEvent? + + fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) +} + +/** + * The account's home base for Concord Channels: the kind-13302 + * [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is + * the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the + * community secrets (root/salt/epoch/private-channel keys), so decryption yields + * everything needed to re-derive each plane on any device. + * + * Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and + * [liveServers] (the distinct community ids — the "server" rail). [follow]/ + * [unfollow] read-modify-write the list; the caller publishes the returned event. + */ +class ConcordChannelListState( + val signer: NostrSigner, + val cache: ICacheProvider, + val scope: CoroutineScope, + val settings: ConcordListRepository, +) { + // Long-term reference so the GC doesn't collect the note itself. + val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress()) + + fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey) + + fun getConcordListFlow(): StateFlow = concordListNote.flow().metadata.stateFlow + + fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent + + /** Decrypts the current list (or the offline backup) into its entries. */ + suspend fun entriesWithBackup(note: Note): List { + val event = note.event as? ConcordCommunityListEvent ?: settings.concordList() + return event?.decrypt(signer) ?: emptyList() + } + + @OptIn(ExperimentalCoroutinesApi::class) + val liveCommunities: StateFlow> = + getConcordListFlow() + .transformLatest { noteState -> + emit(entriesWithBackup(noteState.note)) + }.onStart { + emit(entriesWithBackup(concordListNote)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + + /** The distinct community ids across the joined list — the "servers" rail. */ + @OptIn(ExperimentalCoroutinesApi::class) + val liveServers: StateFlow> = + liveCommunities + .transformLatest { entries -> emit(entries.mapTo(mutableSetOf()) { it.id }) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + /** Add or replace [entry] (by community id) and return the new signed list event to publish. */ + suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent { + val current = getConcordList()?.decrypt(signer).orEmpty() + val next = current.filterNot { it.id == entry.id } + entry + return ConcordCommunityListEvent.create(signer, next) + } + + /** Drop the community with [communityId] and return the new list event, or null if none existed. */ + suspend fun unfollow(communityId: String): ConcordCommunityListEvent? { + val event = getConcordList() ?: return null + val next = event.decrypt(signer).filterNot { it.id == communityId } + return ConcordCommunityListEvent.create(signer, next) + } + + init { + settings.concordList()?.let { event -> + Log.d("AccountRegisterObservers", "Loading saved concord list") + @OptIn(DelicateCoroutinesApi::class) + scope.launch(Dispatchers.IO) { + cache.justConsumeMyOwnEvent(event) + } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "ConcordList Collector Start") + getConcordListFlow().collect { noteState -> + (noteState.note.event as? ConcordCommunityListEvent)?.let { + settings.updateConcordListTo(it) + } + } + } + } +} From e560df6edcc658b9ad65831b7977387b89c9c988 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 18:25:52 +0000 Subject: [PATCH 022/115] feat(concord): add ConcordChannel model + LocalCache index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel model the shared chat UI renders, mirroring RelayGroupChannel: - quartz ConcordChannelId(communityId, channelId): the stable channel address (analog of NIP-29 GroupId, but community-scoped rather than relay-pinned) - commons ConcordChannel : Channel — name/voice/private from the folded Control Plane (no single relay-signed metadata event), relays() = the community relay set (a plane may be mirrored on several), membership from the authority resolver, canPost(), placeholderNote() for immediate Messages-list rows; updateFrom(state, relays, myPubKey) refreshes on each re-fold - LocalCache: concordChannels LargeCache index + getOrCreate/getIfExists Compiles across :quartz/:commons/:amethyst. Next: the decryption subscription that folds inbound channel-plane wraps into these, then the Messages inbox hook. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/model/LocalCache.kt | 7 ++ .../commons/model/concord/ConcordChannel.kt | 115 ++++++++++++++++++ .../cord03Channels/ConcordChannelId.kt | 40 ++++++ 3 files changed, 162 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 09bbdc0e5f..1f360aa595 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.model.OnchainZapStatus import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache +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 @@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -354,6 +356,7 @@ object LocalCache : ILocalCache, ICacheProvider { val liveChatChannels = LargeCache() val ephemeralChannels = LargeCache() val relayGroupChannels = LargeCache() + val concordChannels = LargeCache() val paymentTracker = NwcPaymentTracker() @@ -722,6 +725,10 @@ object LocalCache : ILocalCache, ICacheProvider { fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) } + fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key) + + fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) } + fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { return getOrCreatePublicChatChannel(key) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt new file mode 100644 index 0000000000..a19bbe71fb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -0,0 +1,115 @@ +/* + * 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.commons.model.concord + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Channel +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * A Concord channel — one encrypted chat room inside a community — as a + * [Channel] the shared chat UI can render, mirroring NIP-29's + * [com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel]. + * + * The key difference: a Concord channel has no single relay-signed metadata + * event. Its name/flags come from the community's **folded Control Plane**, its + * decrypted messages are fed in by a subscription that holds the channel key, and + * it is addressed by the derived plane pubkey rather than a host relay — so + * [relays] is the *community's* relay set (a channel may be mirrored on several), + * not a single host. Membership derives from the owner-rooted authority resolver. + */ +@Stable +class ConcordChannel( + val channelId: ConcordChannelId, +) : Channel() { + /** Channel display name from the folded ChannelMetadata, when known. */ + var channelName: String? = null + private set + + var isVoice: Boolean = false + private set + + var isPrivate: Boolean = false + private set + + /** The parent community's display name, from its folded metadata. */ + var communityName: String? = null + private set + + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ + var communityRelays: Set = emptySet() + private set + + /** This account's standing in the community (from the authority resolver + banlist). */ + var membership: ConcordMembership = ConcordMembership.MEMBER + private set + + /** + * Refresh this channel's metadata from a freshly-folded community [state] plus + * the community's [relays] and this account's [myPubKey]. Cheap and idempotent + * — called whenever the Control Plane re-folds. + */ + fun updateFrom( + state: ConcordCommunityState, + relays: Set, + myPubKey: HexKey, + ) { + state.channels[channelId.channelId]?.definition?.let { + channelName = it.name + isVoice = it.voice + isPrivate = it.private + } + communityName = state.metadata?.name + communityRelays = relays + membership = ConcordMembership.of(state.authority, myPubKey) + } + + /** A Concord channel is reachable on any of its community's relays. */ + override fun relays(): Set = communityRelays + + override fun toBestDisplayName(): String = channelName ?: channelId.channelId + + fun canPost(): Boolean = membership.isMember() + + // Synthetic note representing this channel in the Messages list before any + // message has loaded (so a just-joined channel appears immediately). Mirrors + // RelayGroupChannel.placeholderNote(). + private val placeholderLock = KmpLock() + private var cachedPlaceholder: Note? = null + + fun placeholderNote(): Note = + placeholderLock.withLock { + cachedPlaceholder ?: Note(placeholderIdHex(channelId)).apply { + addGatherer(this@ConcordChannel) + cachedPlaceholder = this + } + } + + companion object { + fun placeholderIdHex(channelId: ConcordChannelId): HexKey = "concord-empty-${channelId.toKey()}" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt new file mode 100644 index 0000000000..07724c4a1b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChannelId.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The stable identity of a Concord channel: the community it belongs to plus the + * channel's own id. Mirrors NIP-29's `GroupId` (host relay + group id) — the + * universal address a UI keys channels by — but a Concord channel is scoped to a + * *community* (whose secret unlocks it), not a host relay. + */ +data class ConcordChannelId( + val communityId: HexKey, + val channelId: HexKey, +) : Comparable { + fun toKey(): String = "$channelId@$communityId" + + override fun compareTo(other: ConcordChannelId): Int = toKey().compareTo(other.toKey()) + + override fun toString(): String = toKey() +} From a1ae4220e9b927646a5faa9f47635ee33adb42b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 18:48:12 +0000 Subject: [PATCH 023/115] feat(concord): add subscription planner (warm all joined planes) The commons half of the "keep channels live" subscription, mirroring NIP-29's RelayGroupMyJoinedGroupsFilterAssembler: turns the account's joined-communities list into per-plane REQs by derived stream address (there is no #p=me for Concord, so each plane is fetched by authors=[planePk]). - ConcordSubscriptionPlanner.controlPlaneSubs: one Control Plane sub per joined community (known from the entry's secrets alone) - channelPlaneSubs: one Chat Plane sub per channel once the Control Plane folds - filtersByRelay: collapses subs to one kind-1059 author filter per relay - ConcordActions.planeFilterFor(authors): multi-author plane filter Test derives a community, asserts the control/channel sub addresses equal the derived plane pks and that the per-relay filter carries both. Green on :commons:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 3 + .../actions/ConcordSubscriptionPlanner.kt | 93 +++++++++++++++++++ .../actions/ConcordSubscriptionPlannerTest.kt | 74 +++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 188cb3f092..658cf5c43e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -81,6 +81,9 @@ object ConcordActions { /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = listOf(planePubKeyHex)) + /** Wraps across several plane addresses on one relay: kind-1059 authored by any of them. */ + fun planeFilterFor(planePubKeysHex: List): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = planePubKeysHex) + /** The public invite bundle for a link signer. */ fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.INVITE_BUNDLE), authors = listOf(linkSignerPubKeyHex)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt new file mode 100644 index 0000000000..05c9442037 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -0,0 +1,93 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * One plane to subscribe to: its stream address ([pubKeyHex]) and the relays it + * may live on. [channelId] is null for a community's Control Plane and set for a + * channel's Chat Plane. + */ +data class ConcordPlaneSub( + val channelId: ConcordChannelId?, + val pubKeyHex: String, + val relays: Set, +) + +/** + * Turns the account's joined-communities list into the per-plane relay + * subscriptions that keep Concord Channels live — the Concord analog of NIP-29's + * `RelayGroupMyJoinedGroupsFilterAssembler`. + * + * Because a Concord wrap's `p` tag is ephemeral, there is no single `#p=me` + * subscription; instead each plane is fetched by its derived stream address + * (`authors=[planePk]`). The "warm all joined planes" policy is encoded here: + * every community's Control Plane is subscribed upfront ([controlPlaneSubs]), and + * once its Control Plane folds, every channel's Chat Plane is subscribed + * ([channelPlaneSubs]). + */ +object ConcordSubscriptionPlanner { + /** Control-plane subscriptions for every joined community (known from the entry alone). */ + fun controlPlaneSubs(entries: List): List = + entries.map { e -> + val cp = ConcordActions.controlPlane(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) + ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays)) + } + + /** Chat-plane subscriptions for every live channel in a folded community [state]. */ + fun channelPlaneSubs( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + ): List { + val root = entry.root.hexToByteArray() + val relays = normalize(entry.relays) + return state.channels.keys.map { channelIdHex -> + val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + ConcordPlaneSub( + channelId = ConcordChannelId(entry.id, channelIdHex), + pubKeyHex = ch.publicKeyHex, + relays = relays, + ) + } + } + + /** + * Collapses [subs] into a `relay -> [filter]` map ready for a drain/subscribe. + * All plane wraps are kind-1059 authored by the plane address, so each relay + * gets one `{kinds:[1059], authors:[…all plane pks on it…]}` filter. + */ + fun filtersByRelay(subs: List): Map> { + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { ArrayList() }.add(sub.pubKeyHex) + } + return authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors)) } + } + + private fun normalize(urls: List): Set = urls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt new file mode 100644 index 0000000000..ca70488ff5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -0,0 +1,74 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConcordSubscriptionPlannerTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun controlAndChannelSubsMatchDerivedAddresses() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + // Control-plane sub address must equal the derived control plane pk. + val controlSubs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) + assertEquals(1, controlSubs.size) + assertEquals(community.controlPlane.publicKeyHex, controlSubs[0].pubKeyHex) + assertTrue(controlSubs[0].channelId == null) + + // Channel-plane subs cover the folded #general channel. + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) + val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex } + assertTrue(general != null) + assertEquals( + ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch).publicKeyHex, + general.pubKeyHex, + ) + + // filtersByRelay collapses to one kind-1059 author filter per relay. + val filters = ConcordSubscriptionPlanner.filtersByRelay(controlSubs + channelSubs) + assertEquals(1, filters.size) // single relay + val filter = filters.values.first().first() + assertEquals(listOf(1059), filter.kinds) + assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex)) + assertTrue(filter.authors!!.contains(general.pubKeyHex)) + } +} From 6178a4e3c6bc942b21c07440c1a8ee40b61d392d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 18:51:08 +0000 Subject: [PATCH 024/115] feat(concord): add ConcordPlaneRegistry (route + decrypt inbound wraps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-path counterpart to the subscription planner: maps derived plane addresses (group_key.pk) to the keys that open them, so an inbound kind-1059 wrap is recognized as Concord traffic and decrypted with the right per-plane key. - registerControlPlanes(entries): control addresses, known from secrets alone - registerChannels(entry, foldedState): channel addresses, known after the Control Plane folds - route(wrap): if wrap.pubkey is a registered plane, open+verify and return the routed rumor with its plane kind/community/channel; else null Because the wrap p-tag is ephemeral, address matching is the only route — a non-member never registers the address, so never decrypts. Thread-safe. Test routes a genesis control wrap to CONTROL and a channel message to CHANNEL (right channel id + decrypted content), and rejects a wrap from another community. Green on :commons:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../model/concord/ConcordPlaneRegistry.kt | 114 ++++++++++++++++++ .../model/concord/ConcordPlaneRegistryTest.kt | 83 +++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt new file mode 100644 index 0000000000..86699776e7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -0,0 +1,114 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** What kind of plane an address belongs to. */ +enum class ConcordPlaneKind { + CONTROL, + CHANNEL, +} + +/** A known Concord plane: its kind, community, optional channel, and the key to open its wraps. */ +class ConcordPlane( + val kind: ConcordPlaneKind, + val communityId: HexKey, + val channelId: ConcordChannelId?, + val key: GroupKey, +) + +/** The routed result of opening an inbound wrap that belonged to a known plane. */ +class RoutedRumor( + val plane: ConcordPlane, + val opened: OpenedStreamEvent, +) + +/** + * Maps derived plane addresses (`group_key.pk`) to the keys that open them, so an + * inbound kind-1059 wrap can be recognized as Concord traffic and decrypted with + * the right per-plane key. + * + * This is the counterpart to [ConcordChannelListState] on the read path: the list + * gives the community secrets, and this registry expands them into the concrete + * plane addresses to watch. Because a Concord wrap's `p` tag is ephemeral, address + * matching (`wrap.pubkey` → registered plane) is the only way to route it — a + * non-member never registers the address, so they never decrypt. + * + * Control-plane addresses are known from a community entry alone; channel-plane + * addresses become known only after the Control Plane folds ([registerChannels]). + * Thread-safe so the ingest path and UI can share one registry. + */ +class ConcordPlaneRegistry { + private val lock = KmpLock() + private val planes = HashMap() + + /** Registers every joined community's Control Plane address. Idempotent. */ + fun registerControlPlanes(entries: List) = + lock.withLock { + for (e in entries) { + val cp = ConcordKeyDerivation.controlPlaneKey(e.root.hexToByteArray(), e.id.hexToByteArray(), e.rootEpoch) + planes[cp.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CONTROL, e.id, null, cp) + } + } + + /** Registers the Chat Plane address of every channel in a folded community [state]. */ + fun registerChannels( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + ) = lock.withLock { + val root = entry.root.hexToByteArray() + for (channelIdHex in state.channels.keys) { + val ch = + com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys + .publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch) + } + } + + /** True if [pubKeyHex] is a Concord plane address this account can open. */ + fun isKnownPlane(pubKeyHex: HexKey): Boolean = lock.withLock { pubKeyHex in planes } + + fun planeFor(pubKeyHex: HexKey): ConcordPlane? = lock.withLock { planes[pubKeyHex] } + + /** + * If [wrap] is a kind-1059 event at a registered plane address, opens it and + * returns the routed rumor; otherwise null (not Concord, or not ours to read). + */ + fun route(wrap: Event): RoutedRumor? { + val plane = planeFor(wrap.pubKey) ?: return null + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane.key) ?: return null + return RoutedRumor(plane, opened) + } + + fun clear() = lock.withLock { planes.clear() } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt new file mode 100644 index 0000000000..09e5c3ae85 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -0,0 +1,83 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordPlaneRegistryTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun routesControlAndChannelWrapsAndRejectsOutsiders() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + val registry = ConcordPlaneRegistry() + registry.registerControlPlanes(listOf(entry)) + + // A genesis control wrap routes to the CONTROL plane. + val controlWrap = community.genesisWraps.first() + assertTrue(registry.isKnownPlane(controlWrap.pubKey)) + val routedControl = registry.route(controlWrap) + assertNotNull(routedControl) + assertEquals(ConcordPlaneKind.CONTROL, routedControl.plane.kind) + assertEquals(community.communityIdHex, routedControl.plane.communityId) + + // After folding + registering channels, a channel message routes to CHANNEL. + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + registry.registerChannels(entry, state) + + val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + val msgWrap = ConcordActions.buildChannelMessage(owner, channel, community.generalChannelIdHex, community.rootEpoch, "gm", 2L) + val routedMsg = registry.route(msgWrap) + assertNotNull(routedMsg) + assertEquals(ConcordPlaneKind.CHANNEL, routedMsg.plane.kind) + assertEquals(community.generalChannelIdHex, routedMsg.plane.channelId?.channelId) + assertEquals(ConcordKinds.MESSAGE, routedMsg.opened.rumor.kind) + assertEquals("gm", routedMsg.opened.rumor.content) + + // A wrap from an unrelated plane (different community) is not ours. + val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) + assertNull(registry.route(outsider.genesisWraps.first())) + } +} From 53c3a922886c3631bb8879af4d965f5f25e0e53c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:23:43 +0000 Subject: [PATCH 025/115] feat(concord): add ConcordCommunitySession read-model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live, stateful read-model of one joined community that a screen/ViewModel binds to and a subscription feeds — bridging the pure logic to the UI: - derives the Control Plane address up front (from the entry's secrets) - ingest(wrap) routes by stream address: control wraps re-fold into a state StateFlow (metadata + channels + authority) and re-derive each channel's Chat Plane address; channel wraps re-project into per-channel message flows - exposes controlPlaneAddress + channelAddresses() (what to subscribe to), state, membership(), and messagesFlow(channelId) - thread-safe; unknown wraps (other communities) are ignored Test: feed genesis control wraps -> "Nostrichs" + #general + OWNER membership + the general plane becomes a known address; feed a channel message -> it lands in #general's flow; a stray community's wrap is ignored. Green on :commons:jvmTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../model/concord/ConcordCommunitySession.kt | 135 ++++++++++++++++++ .../concord/ConcordCommunitySessionTest.kt | 78 ++++++++++ 2 files changed, 213 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt new file mode 100644 index 0000000000..b6970c243d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -0,0 +1,135 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChatMessage +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * The live read-model of one joined Concord community, driven by inbound stream + * wraps. A screen/ViewModel binds to its flows; a subscription feeds it via + * [ingest]. + * + * It holds the community's [entry] (with secrets), derives the Control Plane + * address up front, and — as control wraps arrive — re-folds the Control Plane + * into [state] (metadata + channels + authority) and re-derives each channel's + * Chat Plane address so subsequent channel wraps route to per-channel + * [messagesFlow]s. This is the stateful counterpart to the pure + * [ConcordActions]/[ConcordPlaneRegistry] helpers. + */ +class ConcordCommunitySession( + val entry: ConcordCommunityListEntry, + val myPubKey: HexKey, +) { + private val root = entry.root.hexToByteArray() + private val communityIdBytes = entry.id.hexToByteArray() + + private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch) + + /** The Control Plane stream address to subscribe to (known from the entry alone). */ + val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex + + private val lock = KmpLock() + + // Deduped inbound wraps. + private val controlWraps = LinkedHashMap() + private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) + + // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. + private var channelKeysByAddress = HashMap>() + + private val _state = MutableStateFlow(null) + val state: StateFlow = _state + + private val messageFlows = HashMap>>() + + /** The current Chat Plane addresses to subscribe to, one per folded channel. */ + fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + + /** A flow of decrypted, ordered messages for the given channel (created on first use). */ + fun messagesFlow(channelIdHex: HexKey): StateFlow> = lock.withLock { messageFlows.getOrPut(channelIdHex) { MutableStateFlow(emptyList()) } } + + /** This account's standing, from the current fold. */ + fun membership(): ConcordMembership { + val s = _state.value ?: return ConcordMembership.MEMBER + return ConcordMembership.of(s.authority, myPubKey) + } + + /** + * Ingests a stream [wrap]. If it belongs to this community's Control Plane it + * re-folds; if it belongs to a known channel plane it re-projects that + * channel's messages. Returns true if the wrap was recognized and applied. + */ + fun ingest(wrap: Event): Boolean { + when (wrap.pubKey) { + controlPlaneAddress -> { + lock.withLock { + if (controlWraps.put(wrap.id, wrap) != null) return true // dup + } + refold() + return true + } + else -> { + val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return false + val (channelIdHex, _) = channelRef + lock.withLock { + channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) + } + reprojectChannel(channelIdHex) + return true + } + } + } + + private fun refold() { + val wraps = lock.withLock { controlWraps.values.toList() } + val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) + _state.value = folded + + // Re-derive channel plane addresses from the fresh fold. + val next = HashMap>() + for (channelIdHex in folded.channels.keys) { + val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + next[key.publicKeyHex] = channelIdHex to key + } + lock.withLock { channelKeysByAddress = next } + + // Any channel wraps already buffered can now project. + for (channelIdHex in folded.channels.keys) reprojectChannel(channelIdHex) + } + + private fun reprojectChannel(channelIdHex: HexKey) { + val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return + val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return + val msgs = ConcordActions.channelMessages(wraps, key, channelIdHex, entry.rootEpoch) + lock.withLock { messageFlows.getOrPut(channelIdHex) { MutableStateFlow(emptyList()) } }.value = msgs + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt new file mode 100644 index 0000000000..5d73a6b492 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -0,0 +1,78 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConcordCommunitySessionTest { + private val owner = NostrSignerInternal(KeyPair()) + + @Test + fun ingestsControlThenChannelWrapsIntoFlows() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + val session = ConcordCommunitySession(entry, owner.pubKey) + assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress) + + // Feed the genesis control wraps → state folds, channels + membership resolve. + community.genesisWraps.forEach { assertTrue(session.ingest(it)) } + val state = session.state.value + assertEquals("Nostrichs", state?.metadata?.name) + assertTrue(state!!.channels.containsKey(community.generalChannelIdHex)) + assertEquals(ConcordMembership.OWNER, session.membership()) + + // The #general channel plane is now a known address. + val general = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) + assertTrue(session.channelAddresses().contains(general.publicKeyHex)) + + // A channel message wrap routes to #general's flow. + val msgWrap = ConcordActions.buildChannelMessage(owner, general, community.generalChannelIdHex, community.rootEpoch, "gm all", 2L) + assertTrue(session.ingest(msgWrap)) + val msgs = session.messagesFlow(community.generalChannelIdHex).value + assertEquals(1, msgs.size) + assertEquals("gm all", msgs[0].content) + assertEquals(owner.pubKey, msgs[0].author) + + // A stray wrap from a different community is ignored. + val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) + assertTrue(!session.ingest(outsider.genesisWraps.first())) + } +} From faa9aa732e439006d7dfb5fa14414f9276d37099 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:30:55 +0000 Subject: [PATCH 026/115] feat(concord): add ConcordSessionRegistry account-wide coordinator Holds one live ConcordCommunitySession per joined community, fans inbound kind-1059 wraps out to the owning session, and exposes the union of control/channel plane addresses to subscribe. sync() reconciles sessions against the joined list while preserving already-folded state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../model/concord/ConcordSessionRegistry.kt | 107 +++++++++++++++++ .../concord/ConcordSessionRegistryTest.kt | 110 ++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt new file mode 100644 index 0000000000..f4d6f7ab54 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -0,0 +1,107 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The account-wide coordinator for every joined Concord community: it holds one + * live [ConcordCommunitySession] per community id and fans inbound stream wraps + * out to whichever session owns them. This is the read-path analog of + * [ConcordChannelListState] on the write path — the list yields the joined + * [ConcordCommunityListEntry] set, this expands each into a folding read-model. + * + * The app layer drives it from two directions: + * - [sync] whenever the joined list changes (from `liveCommunities`), which + * creates sessions for new communities and drops sessions for departed ones + * while **preserving** the already-folded state of the ones that remain. + * - [ingest] for every inbound kind-1059 wrap, which routes it to the matching + * session (control plane → re-fold; channel plane → re-project messages). + * + * [subscribeAddresses] returns the union of every session's control- and + * channel-plane addresses — exactly the `authors` set a subscription must watch + * for kind-1059 wraps. Thread-safe: the ingest path and UI share one instance. + */ +class ConcordSessionRegistry { + private val lock = KmpLock() + + // communityId -> live folding session. Insertion-ordered for stable iteration. + private val sessions = LinkedHashMap() + + /** + * Reconcile the held sessions with the current joined [entries]. Sessions for + * communities still present are kept as-is (their folded state survives); + * sessions for communities no longer joined are dropped; new communities get a + * fresh session. Returns the set of community ids whose sessions were created. + */ + fun sync( + entries: List, + myPubKey: HexKey, + ): Set = + lock.withLock { + val wanted = entries.associateBy { it.id } + // Drop sessions for communities we've left. + sessions.keys.retainAll(wanted.keys) + // Add sessions for newly-joined communities. + val created = mutableSetOf() + for ((id, entry) in wanted) { + if (id !in sessions) { + sessions[id] = ConcordCommunitySession(entry, myPubKey) + created += id + } + } + created + } + + fun sessionFor(communityId: HexKey): ConcordCommunitySession? = lock.withLock { sessions[communityId] } + + fun sessions(): List = lock.withLock { sessions.values.toList() } + + /** The union of control- and channel-plane addresses across all sessions to subscribe to. */ + fun subscribeAddresses(): Set = + lock.withLock { + val out = HashSet() + for (session in sessions.values) { + out += session.controlPlaneAddress + out += session.channelAddresses() + } + out + } + + /** + * Routes an inbound stream [wrap] to whichever session recognizes it. Returns + * true if some session applied it. A wrap belongs to at most one plane, so the + * first accepting session wins. + */ + fun ingest(wrap: Event): Boolean { + val snapshot = lock.withLock { sessions.values.toList() } + for (session in snapshot) { + if (session.ingest(wrap)) return true + } + return false + } + + fun clear() = lock.withLock { sessions.clear() } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt new file mode 100644 index 0000000000..29b600f620 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -0,0 +1,110 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordSessionRegistryTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun entryFor( + community: NewConcordCommunity, + name: String, + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = name, + ) + + @Test + fun syncsSessionsRoutesWrapsAndDropsDepartedCommunities() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) + + val registry = ConcordSessionRegistry() + + // First sync creates a session for each joined community. + val created = registry.sync(listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")), owner.pubKey) + assertEquals(setOf(alpha.communityIdHex, beta.communityIdHex), created) + assertNotNull(registry.sessionFor(alpha.communityIdHex)) + assertNotNull(registry.sessionFor(beta.communityIdHex)) + + // Both control-plane addresses are in the subscribe set from the entries alone. + assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) + assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + + // A genesis control wrap routes to Alpha's session and folds it. + alpha.genesisWraps.forEach { assertTrue(registry.ingest(it)) } + val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value + assertEquals("Alpha", alphaState?.metadata?.name) + + // After the fold, Alpha's #general channel plane joins the subscribe set. + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + assertTrue(registry.subscribeAddresses().contains(general.publicKeyHex)) + + // A channel message routes to Alpha's #general flow, not Beta. + val msg = ConcordActions.buildChannelMessage(owner, general, alpha.generalChannelIdHex, alpha.rootEpoch, "gm", 2L) + assertTrue(registry.ingest(msg)) + assertEquals( + 1, + registry + .sessionFor(alpha.communityIdHex)!! + .messagesFlow(alpha.generalChannelIdHex) + .value.size, + ) + + // A re-sync that keeps Alpha but drops Beta preserves Alpha's folded state and removes Beta. + val createdAgain = registry.sync(listOf(entryFor(alpha, "Alpha")), owner.pubKey) + assertTrue(createdAgain.isEmpty()) + assertNotNull(registry.sessionFor(alpha.communityIdHex)) + assertNull(registry.sessionFor(beta.communityIdHex)) + assertEquals( + "Alpha", + registry + .sessionFor(alpha.communityIdHex)!! + .state.value + ?.metadata + ?.name, + ) + + // A wrap from an unknown community is routed nowhere. + val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example")) + assertFalse(registry.ingest(gamma.genesisWraps.first())) + } +} From 8f34f5f7d28e6e5c844e1c46189e7f2d9f946fd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:38:10 +0000 Subject: [PATCH 027/115] feat(concord): wire ConcordSessionManager into Account + giftwrap route Adds ConcordSessionManager (commons) that keeps one folding session per joined community in step with the concord list and turns folds into an observable revision the app watches to re-derive subscription filters. Account owns one per account; the giftwrap decrypt path (GiftWrapEventHandler) routes recognized Concord plane wraps to it before the NIP-59 DM check, so ephemeral-p Concord wraps fold instead of being dropped as undecryptable DMs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 9 ++ .../loggedIn/DecryptAndIndexProcessor.kt | 6 + .../model/concord/ConcordSessionManager.kt | 123 ++++++++++++++++++ .../concord/ConcordSessionManagerTest.kt | 100 ++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 31075260c7..1447738c99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -27,6 +27,7 @@ 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.ConcordChannelListState +import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState @@ -386,6 +387,14 @@ class Account( val concordChannelList = ConcordChannelListState(signer, cache, scope, settings) + /** + * The live read-path for joined Concord Channels: one folding session per + * community, fed by inbound kind-1059 plane wraps. Kept in step with + * [concordChannelList] and consulted by the giftwrap decrypt path so a Concord + * plane wrap routes here instead of being dropped as an undecryptable DM. + */ + val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope) + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index e05a9627a1..b66b04c868 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -272,6 +272,12 @@ class GiftWrapEventHandler( eventNote: Note, publicNote: Note, ) { + // Concord plane wraps are kind-1059 too, but their `p` tag is ephemeral and + // the payload opens with a derived plane key, not our identity — so route + // them to the Concord read-path first. A recognized wrap is fully handled + // there (folded / re-projected) and must not fall through to the DM path. + if (account.concordSessions.ingest(event)) return + if (event.recipientPubKey() != account.signer.pubKey) return val innerGiftId = event.innerEventId diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt new file mode 100644 index 0000000000..d7acc532da --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -0,0 +1,123 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * The account-scoped owner of the live Concord read-path. It keeps the + * [ConcordSessionRegistry] in step with the joined-communities list and turns + * each community's folds into a single observable tick the app layer watches to + * (re)derive subscription filters and refresh its channel index. + * + * Wiring, per account: + * - Construct once with the account's `liveCommunities` flow, its pubkey, and a + * long-lived [scope]. It self-starts a collector that [ConcordSessionRegistry.sync]s + * on every list change and, for each **new** session, watches its + * [ConcordCommunitySession.state] so a fold bumps [revision]. + * - Feed every inbound kind-1059 wrap through [ingest]; a Concord plane wrap is + * applied (control → re-fold, channel → re-project) and returns true, so the + * caller can stop treating it as a NIP-59 DM. + * - Read [subscribeAddresses] to build the `authors` set for the kind-1059 + * subscription; re-read it whenever [revision] advances (a fold reveals new + * channel planes to watch). + * + * Everything platform-specific (the LocalCache channel index, the actual REQ + * mounting) stays in the app layer, which reacts to [revision]; this class holds + * no Android/UI dependency so it stays unit-testable. + */ +class ConcordSessionManager( + private val communities: StateFlow>, + private val myPubKey: HexKey, + private val scope: CoroutineScope, +) { + val registry = ConcordSessionRegistry() + + private val _revision = MutableStateFlow(0) + + /** Monotonic counter bumped whenever the joined set or any community's fold changes. */ + val revision: StateFlow = _revision + + private val lock = KmpLock() + private val stateWatchers = HashMap() // communityId -> state collector + + init { + scope.launch { + communities.collect { entries -> onCommunitiesChanged(entries) } + } + } + + private fun onCommunitiesChanged(entries: List) { + val created = registry.sync(entries, myPubKey) + val wantedIds = entries.mapTo(HashSet()) { it.id } + + lock.withLock { + // Cancel watchers for communities we've left. + val departed = stateWatchers.keys.filterNot { it in wantedIds } + for (id in departed) stateWatchers.remove(id)?.cancel() + + // Watch each newly-created session so its folds bump the revision. + for (id in created) { + val session = registry.sessionFor(id) ?: continue + stateWatchers[id] = + scope.launch { + session.state.collect { bumpRevision() } + } + } + } + bumpRevision() + } + + private fun bumpRevision() { + _revision.value = _revision.value + 1 + } + + /** The `authors` set (control + known channel planes) for the kind-1059 subscription. */ + fun subscribeAddresses(): Set = registry.subscribeAddresses() + + /** Route an inbound stream wrap; true if it was a Concord plane wrap we applied. */ + fun ingest(wrap: Event): Boolean { + val applied = registry.ingest(wrap) + if (applied) bumpRevision() + return applied + } + + fun sessions() = registry.sessions() + + fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId) + + fun destroy() { + lock.withLock { + stateWatchers.values.forEach { it.cancel() } + stateWatchers.clear() + } + registry.clear() + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt new file mode 100644 index 0000000000..044dccde7a --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -0,0 +1,100 @@ +/* + * 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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ConcordSessionManagerTest { + private val owner = NostrSignerInternal(KeyPair()) + + private fun entryFor( + community: NewConcordCommunity, + name: String, + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = name, + ) + + @Test + fun syncsFromFlowFoldsOnIngestAndAdvancesRevision() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha"))) + + val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope) + testScheduler.runCurrent() + + // The joined community produced a session, and its control plane is in the subscribe set. + assertTrue(manager.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) + val revAfterSync = manager.revision.value + assertTrue(revAfterSync > 0) + + // Ingesting the genesis control wraps folds Alpha and advances the revision. + alpha.genesisWraps.forEach { assertTrue(manager.ingest(it)) } + testScheduler.runCurrent() + assertEquals( + "Alpha", + manager + .sessionFor(alpha.communityIdHex) + ?.state + ?.value + ?.metadata + ?.name, + ) + assertTrue(manager.revision.value > revAfterSync) + + // The folded #general channel plane is now part of the subscribe set. + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + assertTrue(manager.subscribeAddresses().contains(general.publicKeyHex)) + + // Joining a second community via the flow creates its session too. + val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) + communities.value = listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")) + testScheduler.runCurrent() + assertTrue(manager.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) + // Alpha's fold survived the re-sync. + assertEquals( + "Alpha", + manager + .sessionFor(alpha.communityIdHex) + ?.state + ?.value + ?.metadata + ?.name, + ) + } +} From f450d03110ba919c78907f8f92f9627ed2f2882c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:40:15 +0000 Subject: [PATCH 028/115] feat(concord): mount live channel subscription + LocalCache index refresh Adds ConcordChannelFilterAssembler (kind-1059 authors=[planePk] per relay, control planes upfront + channel planes once a Control Plane folds) and the ConcordChannelSubscription composable that, on each ConcordSessionManager revision, refreshes the LocalCache ConcordChannel rows from the freshly-folded state and re-derives the filters to pick up newly-revealed channel planes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ConcordChannelFilterAssembler.kt | 112 ++++++++++++++++++ .../datasource/ConcordChannelSubscription.kt | 90 ++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt new file mode 100644 index 0000000000..7bcb6f7254 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -0,0 +1,112 @@ +/* + * 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.datasource + +import com.vitorpamplona.amethyst.commons.actions.ConcordPlaneSub +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** One screen's request to keep the user's joined Concord Channels live. */ +class ConcordChannelQueryState( + val account: Account, +) + +/** + * Keeps every joined Concord community's planes live while a Concord-bearing + * screen is on top — the Concord analog of `RelayGroupMyJoinedGroupsFilterAssembler`. + * + * Unlike NIP-29, a Concord plane wrap's `p` tag is ephemeral, so there is no + * `#p=me` subscription: each plane is fetched by its derived stream address + * (`authors=[planePk]`, kind 1059). Every joined community's Control Plane is + * subscribed upfront; once a Control Plane folds, [Account.concordSessions] bumps + * its revision and this assembler re-derives to also watch each channel's Chat + * Plane (see [ConcordChannelSubscription]). + */ +class ConcordChannelFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ConcordChannelSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +class ConcordChannelSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ConcordChannelQueryState, + since: SincePerRelayMap?, + ): List? { + val account = key.account + val entries = account.concordChannelList.liveCommunities.value + if (entries.isEmpty()) return null + + // Control planes for every joined community, plus channel planes for the + // ones whose Control Plane has already folded. + val subs = ArrayList() + subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) + for (entry in entries) { + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value ?: continue + subs += ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) + } + + // One kind-1059 filter per host relay, carrying every plane address on it. + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + if (authorsByRelay.isEmpty()) return null + + return authorsByRelay.map { (relay, authors) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordKinds.WRAP), + authors = authors.toList(), + since = since?.get(relay)?.time, + ), + ) + } + } + + override fun id(key: ConcordChannelQueryState) = key.account +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt new file mode 100644 index 0000000000..50bb7394e8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -0,0 +1,90 @@ +/* + * 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.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +/** + * Mount on any screen that lists the user's joined Concord Channels (the Messages + * tab, the Concord home) to keep their planes live and their folded metadata in + * the [LocalCache] channel index. + * + * The query state is keyed on the account (stable), so the assembler wouldn't + * re-run its filter derivation on its own when a community folds or the joined set + * changes. We watch [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] + * — bumped on every join/leave and every Control-Plane fold — and on each change: + * 1. refresh the LocalCache [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] + * rows from the freshly-folded state, so list/chat UIs see the new name, + * channels and membership, then + * 2. invalidate the assembler so a newly-revealed channel plane is subscribed + * (its Chat Plane address is only known after the Control Plane folds). + */ +@Composable +fun ConcordChannelSubscription( + dataSource: ConcordChannelFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account) { ConcordChannelQueryState(account) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + refreshConcordChannelIndex(account) + dataSource.invalidateFilters() + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} + +/** + * Projects each folded community session into the shared LocalCache channel index + * so the Messages list and chat screens render an up-to-date [ConcordChannel] + * (name, voice/private flags, community name/relays, this account's membership). + */ +private fun refreshConcordChannelIndex(account: Account) { + val myPubKey = account.signer.pubKey + val relaysByCommunity = + account.concordChannelList.liveCommunities.value + .associate { entry -> + entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + + for (session in account.concordSessions.sessions()) { + val state = session.state.value ?: continue + val communityId = session.entry.id + val relays = relaysByCommunity[communityId] ?: emptySet() + for (channelIdHex in state.channels.keys) { + LocalCache + .getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + .updateFrom(state, relays, myPubKey) + } + } +} From 285317b0c272b6b24b4eb86ee51b46777dfaa6b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:44:26 +0000 Subject: [PATCH 029/115] feat(concord): surface Concord Channels on the Messages screen Adds a 6th concat to ChatroomListKnownFeedFilter: each folded Concord channel is its own Messages row (placeholder note carrying its ConcordChannel gatherer), interleaved with DMs and other chats. ChatroomHeaderCompose renders it via a new ConcordRoomCompose showing the channel name plus a community chip that opens the community's channel list; tapping the row opens the encrypted chat. Adds Concord / ConcordServer / ConcordInvite / Concords navigation routes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/navigation/routes/Routes.kt | 21 +++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 59 ++++++++++++++++++- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 18 +++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e16b15383d..3f7c3a3723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -676,6 +676,27 @@ sealed class Route { @Serializable object RelayGroupBrowse : Route() + // Concord Channels (encrypted communities). Addressed by community id + channel id + // (both lowercase hex), never a host relay — a channel plane may be mirrored on all + // of the community's relays. + @Serializable data class Concord( + val communityId: String, + val channelId: String, + val draftId: HexKey? = null, + val replyTo: HexKey? = null, + ) : Route() + + @Serializable data class ConcordServer( + val communityId: String, + ) : Route() + + // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. + @Serializable data class ConcordInvite( + val link: String, + ) : Route() + + @Serializable object Concords : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index e54a7e3732..980454fbe6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -54,6 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -118,7 +119,7 @@ fun ChatroomHeaderCompose( baseNote is RelayGroupServerRoomNote || ( baseNote.event == null && - baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel } == true + baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true ) if (baseNote.event != null || rendersWithoutEvent) { @@ -173,6 +174,12 @@ private fun ChatroomEntry( return } + val concordChannel = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concordChannel != null) { + ConcordRoomCompose(lastMessage, concordChannel, accountViewModel, nav) + return + } + // A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel // existed, or via a path that skips attach) has no case in the when() below and would blank out. // Resolve the group from its `h` tag + provenance relay and render the group row anyway. @@ -406,6 +413,56 @@ private fun RelayGroupRoomCompose( ) } +@Composable +private fun ConcordRoomCompose( + lastMessage: Note, + baseChannel: ConcordChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by observeChannel(baseChannel, accountViewModel) + val channel = channelState?.channel as? ConcordChannel ?: baseChannel + + // Messages live in the community session's decrypted flow, not as LocalCache notes, so the + // list row has no last-message event; name the parent community on the second line instead. + val lastContent = channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet) + + ChannelName( + channelIdHex = channel.channelId.channelId, + channelPicture = null, + channelTitle = { modifier -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + Text( + text = channel.toBestDisplayName(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + channel.communityName?.let { communityName -> + Spacer(Modifier.width(6.dp)) + // The chip names the parent community and, when tapped, opens that community's + // channel list — the "chip that opens the Concord Channel" entry point. + RelayNameChip( + label = communityName, + onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, + ) + } + } + }, + channelLastTime = lastMessage.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.Concord(channel.channelId.communityId, channel.channelId.channelId)) }, + ) +} + @Composable private fun RelayGroupServerRoomCompose( row: RelayGroupServerRoomNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index 613bb9917f..d3ca95b010 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -128,7 +129,22 @@ class ChatroomListKnownFeedFilter( } } - return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups).toSet()) + // Concord Channels the user joined (kind 13302 list → folded Control Plane). Each folded + // channel is its own Messages row, carrying its ConcordChannel as a gatherer so the header + // renders it and a tap opens the encrypted chat. Messages live in the community session's + // decrypted flow (not LocalCache notes), so the row is a placeholder that the chat screen + // fills in — mirrors the just-joined Marmot/relay-group placeholder path above. + val concordChannels = + account.concordSessions.sessions().flatMap { session -> + val state = session.state.value ?: return@flatMap emptyList() + state.channels.keys.map { channelIdHex -> + LocalCache + .getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + .placeholderNote() + } + } + + return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups + concordChannels).toSet()) } override fun updateListWith( From 1e5d062e32c3c8fca346cf23068d66470b66ca82 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:51:55 +0000 Subject: [PATCH 030/115] feat(concord): chat + channel-list screens, send path, nav routes - ConcordChannelScreen: renders a channel's decrypted message flow with a composer; posting derives the channel plane key and publishes an encrypted wrap to the community relays (Account.sendConcordChannelMessage), with an instant local echo via the session fold. - ConcordChannelListScreen: the community "server" view listing folded channels. - Registers the ConcordChannelFilterAssembler in RelaySubscriptionsCoordinator and wires Route.Concord / Route.ConcordServer into AppNavigation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 30 +++ .../RelaySubscriptionsCoordinator.kt | 7 + .../amethyst/ui/navigation/AppNavigation.kt | 19 ++ .../concord/ConcordChannelListScreen.kt | 104 +++++++++ .../concord/ConcordChannelScreen.kt | 208 ++++++++++++++++++ 5 files changed, 368 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1447738c99..b2bf83389e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences +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 @@ -158,6 +159,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -168,6 +170,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -1488,6 +1491,33 @@ class Account( /** Drop a joined Concord community from the private kind-13302 list by its id. */ suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) + /** + * Post [text] to a Concord channel: derive the channel plane key, build an + * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), + * fold it locally for an instant echo, and publish it to the community's relays. + * The `p` tag is ephemeral, so this never routes through the DM outbox — it goes + * straight to the community relay set. Returns false if not writeable or the + * community isn't currently joined/folded. + */ + suspend fun sendConcordChannelMessage( + communityId: String, + channelIdHex: String, + text: String, + ): 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 wrap = ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now()) + + // Instant local echo, then publish to every relay the community lives on. + 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 ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 0d63313e06..027fedeba5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFil import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.ProfileBadgesFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler @@ -128,6 +129,11 @@ class RelaySubscriptionsCoordinator( val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed + + // Concord Channels (encrypted communities). One assembler keeps every joined community's + // control + channel planes live (kind-1059 by derived stream address). + val concordChannels = ConcordChannelFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -194,6 +200,7 @@ class RelaySubscriptionsCoordinator( relayGroupThreadFeed, relayGroupWarmup, relayGroupsDiscovery, + concordChannels, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 0be3941fad..97841bc732 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -100,6 +100,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen @@ -586,6 +588,23 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordChannelScreen( + communityId = it.communityId, + channelId = it.channelId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordChannelListScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupMembersScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt new file mode 100644 index 0000000000..a8e197b529 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -0,0 +1,104 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Column +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.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.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The channel list of one Concord community (the "server" view). Reads the folded + * Control Plane from the community session and renders one row per channel; tapping + * opens that channel's [ConcordChannelScreen]. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordChannelListScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val account = accountViewModel.account + val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) + .collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(state?.metadata?.name ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name), fontWeight = FontWeight.Bold, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + ) { padding -> + val channels = + state + ?.channels + ?.entries + ?.toList() + .orEmpty() + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(channels, key = { it.key }) { entry -> + val name = entry.value.definition?.name ?: entry.key + Column( + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Concord(communityId, entry.key)) } + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text("# $name", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + } + HorizontalDivider() + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt new file mode 100644 index 0000000000..d6febafc96 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -0,0 +1,208 @@ +/* + * 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.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The chat screen of one Concord Channel. Messages come from the community + * session's decrypted, ordered flow (not LocalCache notes); posting derives the + * channel plane key and publishes an encrypted wrap to the community's relays. + * + * Mounts [ConcordChannelSubscription] so the channel's plane stays live while the + * screen is foregrounded (and so a just-opened channel re-subscribes once its + * Control Plane folds). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordChannelScreen( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val account = accountViewModel.account + val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } + + val messages by (session?.messagesFlow(channelId) ?: remember { MutableStateFlow(emptyList()) }) + .collectAsStateWithLifecycle() + + val scope = rememberCoroutineScope() + var draft by remember { mutableStateOf("") } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(channel.toBestDisplayName(), fontWeight = FontWeight.Bold, maxLines = 1) + channel.communityName?.let { + Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding).imePadding()) { + val listState = rememberLazyListState() + LazyColumn( + modifier = Modifier.weight(1f).fillMaxWidth(), + state = listState, + reverseLayout = true, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(messages.asReversed(), key = { it.id }) { message -> + val mine = message.author == account.signer.pubKey + ConcordMessageBubble( + author = message.author, + content = message.content, + mine = mine, + accountViewModel = accountViewModel, + ) + } + } + + if (channel.canPost()) { + ConcordComposer( + draft = draft, + onDraftChange = { draft = it }, + onSend = { + val text = draft.trim() + if (text.isNotEmpty()) { + draft = "" + scope.launch { account.sendConcordChannelMessage(communityId, channelId, text) } + } + }, + ) + } + } + } +} + +@Composable +private fun ConcordMessageBubble( + author: String, + content: String, + mine: Boolean, + accountViewModel: AccountViewModel, +) { + val user = remember(author) { LocalCache.getOrCreateUser(author) } + val name by observeUserName(user, accountViewModel) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start, + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { + if (!mine) { + Text(name, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + Text(content, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun ConcordComposer( + draft: String, + onDraftChange: (String) -> 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, + ) + } + } + } +} From 9964423ebc74f2a4e8992317b475e25c0e731f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 19:56:19 +0000 Subject: [PATCH 031/115] feat(concord): create, mint-invite, and deep-link join flows Account gains createConcordCommunity (mint genesis, publish, join), mintConcordInvite (publish kind-33301 bundle, return the shareable link), and joinConcordViaInvite (parse link, fetch+unlock bundle, add to the 13302 list). ConcordInviteScreen auto-redeems an invite deep link (Route.ConcordInvite) and forwards to the joined community's channel list, with a retry on relay miss. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 95 +++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 9 ++ .../concord/ConcordInviteScreen.kt | 115 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 221 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b2bf83389e..7548af4b60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -160,6 +160,7 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle @@ -167,6 +168,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -1488,9 +1490,102 @@ class Account( /** Add a joined Concord community (secret-bearing entry) to the private kind-13302 list. */ suspend fun joinConcordCommunity(entry: ConcordCommunityListEntry) = sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) + /** + * Create a new Concord community: mint its genesis (metadata + #general), + * publish the owner-signed genesis wraps to [relays] (or our outbox), and add + * the secret-bearing entry to the kind-13302 joined list. Returns the new + * community id, or null if not writeable. + */ + suspend fun createConcordCommunity( + name: String, + description: String? = null, + relays: List = emptyList(), + ): String? { + if (!isWriteable()) return null + val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } + val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls) + + val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } + community.genesisWraps.forEach { client.publish(it, publishTo) } + + joinConcordCommunity( + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = relayUrls, + name = name, + ), + ) + return community.communityIdHex + } + + /** + * Mint a shareable invite link for a joined community and publish its + * kind-33301 public bundle to the community relays. Returns the `…/invite/…` + * URL, or null if the community isn't joined or isn't writeable. + */ + suspend fun mintConcordInvite( + communityId: String, + base: String = "https://amethyst.social", + ): String? { + if (!isWriteable()) return null + val entry = concordChannelList.liveCommunities.value.firstOrNull { it.id == communityId } ?: return null + val invite = + ConcordActions.inviteFor( + communityIdHex = entry.id, + ownerPubKey = entry.owner, + ownerSaltHex = entry.ownerSalt, + communityRootHex = entry.root, + rootEpoch = entry.rootEpoch, + name = entry.name, + relays = entry.relays, + ) + val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) + + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } + if (publishTo.isNotEmpty()) client.publish(minted.bundleEvent, publishTo) + return minted.url + } + /** Drop a joined Concord community from the private kind-13302 list by its id. */ suspend fun leaveConcordCommunity(communityId: String) = sendMyPublicAndPrivateOutbox(concordChannelList.unfollow(communityId)) + /** + * Redeem a Concord invite link (`…/invite/#`): parse it, fetch + * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it + * with the fragment token, and add the resulting secret-bearing entry to the + * kind-13302 joined list. Returns the joined community id, or null if the link + * is invalid, unreadable, or no valid bundle is found. + */ + suspend fun joinConcordViaInvite(url: String): String? { + if (!isWriteable()) return null + val parsed = ConcordActions.parseInviteLink(url) ?: return null + + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + val bundle = wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } ?: return null + + val entry = + ConcordCommunityListEntry( + id = bundle.communityId, + owner = bundle.owner, + ownerSalt = bundle.ownerSalt, + root = bundle.communityRoot, + rootEpoch = bundle.rootEpoch, + relays = bundle.relays, + name = bundle.name, + ) + joinConcordCommunity(entry) + return bundle.communityId + } + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 97841bc732..6e452e2da6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen @@ -605,6 +606,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteScreen( + link = it.link, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupMembersScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt new file mode 100644 index 0000000000..31c400395f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -0,0 +1,115 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +private sealed interface RedeemState { + data object Working : RedeemState + + data class Done( + val communityId: String, + ) : RedeemState + + data object Failed : RedeemState +} + +/** + * Auto-redeems a Concord invite link (deep-link target for [Route.ConcordInvite]). + * On open it fetches + unlocks the bundle, joins the community, and forwards to its + * channel list. On failure it offers a retry, so a transient relay miss doesn't + * strand the user. + */ +@Composable +fun ConcordInviteScreen( + link: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + var state by remember(link) { mutableStateOf(RedeemState.Working) } + + LaunchedEffect(link, state) { + if (state is RedeemState.Working) { + val communityId = accountViewModel.account.joinConcordViaInvite(link) + state = if (communityId != null) RedeemState.Done(communityId) else RedeemState.Failed + } + } + + LaunchedEffect(state) { + (state as? RedeemState.Done)?.let { done -> + nav.newStack(Route.ConcordServer(done.communityId)) + } + } + + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + is RedeemState.Working -> { + CircularProgressIndicator() + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_redeeming_invite), + modifier = Modifier.padding(top = 16.dp), + textAlign = TextAlign.Center, + ) + } + + is RedeemState.Failed -> { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_failed), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + Button( + onClick = { state = RedeemState.Working }, + modifier = Modifier.padding(top = 16.dp), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.retry)) + } + } + + is RedeemState.Done -> Unit + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f275053e00..74ea51703e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -305,6 +305,8 @@ Already have a Nostr account? Loading feed Loading account + Redeeming invite… + Could not fetch this invite. The link may be expired or its relays unreachable. encrypted legacy Looking for the original message… From 8f0cbc77cfa1ae31c167f689f5754c117f787e63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:00:32 +0000 Subject: [PATCH 032/115] feat(concord): tappable Concord invite links in note content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RichTextParser now classifies `…/invite/#` URLs as a ConcordInviteLinkSegment (cheap substring gate before the base64/bech32 parse), and RichTextViewer renders them via ClickableConcordInviteLink — tap opens the redeem flow, long-press copies the link. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../components/ClickableConcordInviteLink.kt | 74 +++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 2 + .../commons/richtext/RichTextParser.kt | 7 ++ .../richtext/RichTextParserSegments.kt | 10 +++ 4 files changed, 93 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt new file mode 100644 index 0000000000..72b15cfefe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableConcordInviteLink.kt @@ -0,0 +1,74 @@ +/* + * 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.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import kotlinx.coroutines.launch + +/** + * Renders a Concord invite link (`…/invite/#`) inline as a + * tappable link that opens the redeem flow ([Route.ConcordInvite], which fetches + + * unlocks the bundle and joins). Long-press copies the full link. Falls back to + * plain text if the literal can't be parsed (detection should guarantee it does). + */ +@Composable +fun ClickableConcordInviteLink( + linkText: String, + nav: INav, +) { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + + val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) } + + if (parsed == null) { + Text(text = linkText) + return + } + + val clickableModifier = + remember(linkText) { + Modifier.combinedClickable( + onLongClick = { scope.launch { clipboardManager.setText(linkText) } }, + onClick = { nav.nav(Route.ConcordInvite(linkText)) }, + ) + } + + Text( + text = linkText, + modifier = clickableModifier, + color = MaterialTheme.colorScheme.primary, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 183a5395ea..08c1566830 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BlossomUriSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment import com.vitorpamplona.amethyst.commons.richtext.ClinkOfferSegment +import com.vitorpamplona.amethyst.commons.richtext.ConcordInviteLinkSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment import com.vitorpamplona.amethyst.commons.richtext.EmojiSegment import com.vitorpamplona.amethyst.commons.richtext.HashIndexEventSegment @@ -533,6 +534,7 @@ private fun RenderWordWithoutPreview( is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayGroupLinkSegment -> ClickableRelayGroupLink(word.segmentText, nav) + is ConcordInviteLinkSegment -> ClickableConcordInviteLink(word.segmentText, nav) is BlossomUriSegment -> BlossomUriRendererNoPreview(word.segmentText, accountViewModel) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 8a5dc558b6..c751b94cbc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.richtext +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.util.isValidUrl @@ -369,6 +370,12 @@ class RichTextParser { } if (urls.withScheme.contains(word)) { + // A Concord invite link is a plain https URL, so it would otherwise render as a bare + // link. Cheap substring gates keep the base64/bech32 parse off the hot path for + // ordinary URLs; only `…/invite/…#…` shapes are actually decoded. + if (word.contains("/invite/") && word.contains('#') && ConcordActions.parseInviteLink(word) != null) { + return ConcordInviteLinkSegment(word) + } parseNowhereLink(word)?.let { return it } return LinkSegment(word) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 3e8a2a75f6..d5f5890fce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -164,6 +164,16 @@ class RelayGroupLinkSegment( segment: String, ) : Segment(segment) +/** + * A Concord invite link (`…/invite/#`). Rendered as a tappable + * chip that opens the redeem flow; [segmentText] is the whole literal (including + * the URL fragment, which carries the unlock token and never hits a server). + */ +@Immutable +class ConcordInviteLinkSegment( + segment: String, +) : Segment(segment) + @Immutable class BlossomUriSegment( segment: String, From 65502c7cda87311c81f3fa8529281514b1533555 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 20:56:32 +0000 Subject: [PATCH 033/115] refactor(concord): land decrypted messages in LocalCache as real Notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the NIP-28/NIP-17 model instead of a private per-session flow. The community session is now purely a decrypt-and-validate gate: each validated inner rumor is handed to a sink (ConcordRumorSink) that the app wires to LocalCache.consumeConcordRumor — landing it as a real Note keyed by rumor id and attaching message-like kinds (9/1111) to the ConcordChannel. Consequences: messages are first-class Notes, so reactions (kind 7), replies (1111), deletes (5), OTS and zaps wire up automatically by id through the normal consume path; the Messages inbox shows real last-message previews and updates incrementally (filterRelevantConcordMessages, keyed on the ConcordChannel gatherer) instead of feed() rebuilds; and the chat screen reuses the shared ChannelFeedViewModel + RefreshingChatroomFeedView (full note UI) with only the Concord-specific encrypted send path kept bespoke. - ConcordActions.channelRumors returns validated bound rumors (all chat kinds) - ConcordCommunitySession/SessionRegistry/SessionManager thread the sink; the messagesFlow/ConcordChatMessage projection is gone from the session - LocalCache.consumeConcordRumor attaches gatherer before justConsume so the inbox incremental filter can route the row Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 2 +- .../amethyst/model/LocalCache.kt | 25 +++++ .../concord/ConcordChannelScreen.kt | 93 ++++++------------- .../chats/rooms/ChatroomHeaderCompose.kt | 13 ++- .../rooms/dal/ChatroomListKnownFeedFilter.kt | 69 ++++++++++++-- .../commons/actions/ConcordActions.kt | 18 ++++ .../model/concord/ConcordCommunitySession.kt | 33 ++++--- .../model/concord/ConcordSessionManager.kt | 3 +- .../model/concord/ConcordSessionRegistry.kt | 6 +- .../concord/ConcordCommunitySessionTest.kt | 13 +-- .../concord/ConcordSessionRegistryTest.kt | 14 +-- 11 files changed, 180 insertions(+), 109 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 7548af4b60..34279de5e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -398,7 +398,7 @@ class Account( * [concordChannelList] and consulted by the giftwrap decrypt path so a Concord * plane wrap routes here instead of being dropped as an undecryptable DM. */ - val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope) + val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, cache::consumeConcordRumor) val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1f360aa595..0aa37eff60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -729,6 +729,31 @@ object LocalCache : ILocalCache, ICacheProvider { fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) } + /** + * Lands a decrypted Concord chat rumor in the cache as a real Note and, for + * message-like kinds, attaches it to its channel so the shared chat feed and + * the Messages inbox render it (with previews, threading, OTS, reactions/zaps + * reusing the same id-keyed machinery as every other chat). Reactions (kind 7), + * deletes (kind 5), etc. are consumed too — they wire to their target Note by + * `e`-tag through [justConsume] — but are not themselves added as channel rows. + * + * Fed by [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] + * once a wrap decrypts + validates against the folded Control Plane. + */ + fun consumeConcordRumor( + communityId: String, + channelIdHex: String, + rumor: Event, + ) { + // Attach to the channel BEFORE justConsume sets the event and notifies feeds, + // so the note already carries its ConcordChannel gatherer when it flows through + // the Messages-list incremental filter (which routes rows by that gatherer). + if (rumor is ChatEvent || rumor is CommentEvent) { + getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)).addNote(getOrCreateNote(rumor.id)) + } + justConsume(rumor, null, false) + } + fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { if (isValidHex(key)) { return getOrCreatePublicChatChannel(key) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index d6febafc96..5b8e0d98c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -20,25 +20,18 @@ */ 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.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -51,27 +44,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache -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 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** - * The chat screen of one Concord Channel. Messages come from the community - * session's decrypted, ordered flow (not LocalCache notes); posting derives the - * channel plane key and publishes an encrypted wrap to the community's relays. + * The chat screen of one Concord Channel. Messages are real Notes in [LocalCache] + * attached to the channel (landed on decrypt by + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]), so the + * feed reuses the shared [RefreshingChatroomFeedView] — reactions, replies, zaps + * and OTS render exactly as in every other chat. * - * Mounts [ConcordChannelSubscription] so the channel's plane stays live while the - * screen is foregrounded (and so a just-opened channel re-subscribes once its - * Control Plane folds). + * Only the send path is Concord-specific: it derives the channel plane key and + * publishes an encrypted wrap to the community relays + * ([com.vitorpamplona.amethyst.model.Account.sendConcordChannelMessage]). + * [ConcordChannelSubscription] keeps the channel's plane live while foregrounded. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -84,11 +81,14 @@ fun ConcordChannelScreen( ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) val account = accountViewModel.account - val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } - val messages by (session?.messagesFlow(channelId) ?: remember { MutableStateFlow(emptyList()) }) - .collectAsStateWithLifecycle() + val feedViewModel: ChannelFeedViewModel = + viewModel( + key = channel.channelId.toKey() + "ConcordFeedViewModel", + factory = ChannelFeedViewModel.Factory(channel, account), + ) + WatchLifecycleAndUpdateModel(feedViewModel) val scope = rememberCoroutineScope() var draft by remember { mutableStateOf("") } @@ -113,23 +113,15 @@ fun ConcordChannelScreen( }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding).imePadding()) { - val listState = rememberLazyListState() - LazyColumn( - modifier = Modifier.weight(1f).fillMaxWidth(), - state = listState, - reverseLayout = true, - contentPadding = PaddingValues(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(messages.asReversed(), key = { it.id }) { message -> - val mine = message.author == account.signer.pubKey - ConcordMessageBubble( - author = message.author, - content = message.content, - mine = mine, - accountViewModel = accountViewModel, - ) - } + Column(Modifier.weight(1f).fillMaxWidth()) { + RefreshingChatroomFeedView( + feedContentState = feedViewModel.feedState, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Concord/$communityId/$channelId", + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) } if (channel.canPost()) { @@ -149,35 +141,6 @@ fun ConcordChannelScreen( } } -@Composable -private fun ConcordMessageBubble( - author: String, - content: String, - mine: Boolean, - accountViewModel: AccountViewModel, -) { - val user = remember(author) { LocalCache.getOrCreateUser(author) } - val name by observeUserName(user, accountViewModel) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (mine) Arrangement.End else Arrangement.Start, - ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) { - Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { - if (!mine) { - Text(name, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) - } - Text(content, style = MaterialTheme.typography.bodyMedium) - } - } - } -} - @Composable private fun ConcordComposer( draft: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 980454fbe6..3fa55bed44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -423,9 +423,16 @@ private fun ConcordRoomCompose( val channelState by observeChannel(baseChannel, accountViewModel) val channel = channelState?.channel as? ConcordChannel ?: baseChannel - // Messages live in the community session's decrypted flow, not as LocalCache notes, so the - // list row has no last-message event; name the parent community on the second line instead. - val lastContent = channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet) + val author = lastMessage.author + val noteEvent = lastMessage.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + // Event-less placeholder row for a just-joined channel with no messages yet. + channel.communityName ?: stringRes(R.string.relay_group_no_messages_yet) + } ChannelName( channelIdHex = channel.channelId.channelId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index d3ca95b010..cdad33a5d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.util.replace @@ -130,17 +131,20 @@ class ChatroomListKnownFeedFilter( } // Concord Channels the user joined (kind 13302 list → folded Control Plane). Each folded - // channel is its own Messages row, carrying its ConcordChannel as a gatherer so the header - // renders it and a tap opens the encrypted chat. Messages live in the community session's - // decrypted flow (not LocalCache notes), so the row is a placeholder that the chat screen - // fills in — mirrors the just-joined Marmot/relay-group placeholder path above. + // channel is its own Messages row: its newest decrypted message (a real Note in LocalCache, + // attached to the ConcordChannel), or a placeholder for a just-joined channel with no + // messages yet. The note carries its ConcordChannel as a gatherer so the header renders it + // and a tap opens the encrypted chat — same shape as the Marmot/relay-group paths above. val concordChannels = account.concordSessions.sessions().flatMap { session -> val state = session.state.value ?: return@flatMap emptyList() state.channels.keys.map { channelIdHex -> - LocalCache - .getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) - .placeholderNote() + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + channel.notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + ?: channel.placeholderNote() } } @@ -160,11 +164,13 @@ class ChatroomListKnownFeedFilter( // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account) + val newRelevantConcord = filterRelevantConcordMessages(newItems, account) if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty() && - newRelevantRelayGroups.isEmpty() + newRelevantRelayGroups.isEmpty() && + newRelevantConcord.isEmpty() ) { return oldList } @@ -235,6 +241,21 @@ class ChatroomListKnownFeedFilter( } } + newRelevantConcord.forEach { newNotePair -> + var hasUpdated = false + oldList.forEach { oldNote -> + if (newNotePair.key == oldNote.concordRowKey()) { + hasUpdated = true + if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) { + myNewList = myNewList.replace(oldNote, newNotePair.value) + } + } + } + if (!hasUpdated) { + myNewList = myNewList.plus(newNotePair.value) + } + } + return sort(myNewList.toSet()).take(1000) } @@ -246,11 +267,13 @@ class ChatroomListKnownFeedFilter( // Gets the latest message by room from the new items. val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account) val newRelevantRelayGroups = filterRelevantRelayGroupMessages(newItems, account) + val newRelevantConcord = filterRelevantConcordMessages(newItems, account) return if (newRelevantPrivateMessages.isEmpty() && newRelevantPublicMessages.isEmpty() && newRelevantEphemeralChats.isEmpty() && - newRelevantRelayGroups.isEmpty() + newRelevantRelayGroups.isEmpty() && + newRelevantConcord.isEmpty() ) { emptySet() } else { @@ -258,11 +281,37 @@ class ChatroomListKnownFeedFilter( newRelevantPrivateMessages.values + newRelevantPublicMessages.values + newRelevantEphemeralChats.values + - newRelevantRelayGroups.values + newRelevantRelayGroups.values + + newRelevantConcord.values ).toSet() } } + /** The row a Concord note belongs to: its ConcordChannel gatherer's stable key. */ + private fun Note.concordRowKey(): String? = inGatherers?.firstNotNullOfOrNull { (it as? ConcordChannel)?.channelId?.toKey() } + + /** + * Latest Concord message per joined channel from the new items, keyed the same way as + * [concordRowKey] (one row per channel). A Concord message note carries its ConcordChannel + * as a gatherer (attached on decrypt), and only kind-9/1111 message-like rumors are attached + * as rows — reactions/deletes wire to their target note and never become a room's last message. + */ + private fun filterRelevantConcordMessages( + newItems: Set, + account: Account, + ): MutableMap { + val result = mutableMapOf() + newItems.forEach { newNote -> + val key = newNote.concordRowKey() ?: return@forEach + if (newNote.event == null || !account.isAcceptable(newNote)) return@forEach + val lastNote = result[key] + if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { + result[key] = newNote + } + } + return result + } + private fun filterRelevantPublicMessages( newItems: Set, account: Account, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 658cf5c43e..e509371c9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -145,6 +145,24 @@ object ConcordActions { .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } .sortedWith(compareBy({ it.createdAt }, { it.id })) + /** + * Opens the channel [wraps] and returns every validated inner rumor bound to + * [channelId]/[epoch] — messages (kind 9), replies (1111), reactions (7), + * deletes (5), edits, etc. — as typed [Event]s. The caller lands these in a + * store keyed by rumor id so the normal reaction/reply/delete/OTS machinery + * wires up automatically. Deduping is left to that store (rumor ids are stable + * content hashes), so this may return duplicates across mirrored wraps. + */ + fun channelRumors( + wraps: List, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): List = + wraps + .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } + .filter { ChannelChat.isBoundTo(it, channelId, epoch) } + // ---- invites -------------------------------------------------------------- /** Builds a [CommunityInvite] from a freshly created (or joined) community's public info. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index b6970c243d..15ee3779ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions -import com.vitorpamplona.amethyst.commons.actions.ConcordChatMessage import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -33,21 +32,33 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +/** + * A validated inner chat rumor emitted by a session: its parent [communityId] and + * [channelIdHex], plus the typed [rumor] (kind 9 message, 1111 reply, 7 reaction, + * 5 delete, …). The sink lands it in a store keyed by rumor id so the normal + * reaction/reply/delete/OTS/zap machinery wires up automatically. + */ +typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event) -> Unit + /** * The live read-model of one joined Concord community, driven by inbound stream - * wraps. A screen/ViewModel binds to its flows; a subscription feeds it via - * [ingest]. + * wraps fed via [ingest]. * * It holds the community's [entry] (with secrets), derives the Control Plane * address up front, and — as control wraps arrive — re-folds the Control Plane * into [state] (metadata + channels + authority) and re-derives each channel's - * Chat Plane address so subsequent channel wraps route to per-channel - * [messagesFlow]s. This is the stateful counterpart to the pure + * Chat Plane address so subsequent channel wraps decrypt. **It does not store + * messages itself:** each validated chat rumor is handed to [onRumor], whose + * platform-side sink lands it in the shared event store (`LocalCache`) as a real + * Note attached to the channel — so previews, threading, reactions and zaps reuse + * the same machinery every other chat does. Re-emitting is safe because the sink + * dedups by rumor id. This is the stateful counterpart to the pure * [ConcordActions]/[ConcordPlaneRegistry] helpers. */ class ConcordCommunitySession( val entry: ConcordCommunityListEntry, val myPubKey: HexKey, + private val onRumor: ConcordRumorSink = { _, _, _ -> }, ) { private val root = entry.root.hexToByteArray() private val communityIdBytes = entry.id.hexToByteArray() @@ -69,14 +80,9 @@ class ConcordCommunitySession( private val _state = MutableStateFlow(null) val state: StateFlow = _state - private val messageFlows = HashMap>>() - /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } - /** A flow of decrypted, ordered messages for the given channel (created on first use). */ - fun messagesFlow(channelIdHex: HexKey): StateFlow> = lock.withLock { messageFlows.getOrPut(channelIdHex) { MutableStateFlow(emptyList()) } } - /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { val s = _state.value ?: return ConcordMembership.MEMBER @@ -129,7 +135,10 @@ class ConcordCommunitySession( private fun reprojectChannel(channelIdHex: HexKey) { val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return - val msgs = ConcordActions.channelMessages(wraps, key, channelIdHex, entry.rootEpoch) - lock.withLock { messageFlows.getOrPut(channelIdHex) { MutableStateFlow(emptyList()) } }.value = msgs + // Decrypt + validate every bound rumor and hand it to the sink. The sink dedups + // by rumor id, so re-emitting the whole buffer on each fold is idempotent. + ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor -> + onRumor(entry.id, channelIdHex, rumor) + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index d7acc532da..e68b6bc171 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -57,8 +57,9 @@ class ConcordSessionManager( private val communities: StateFlow>, private val myPubKey: HexKey, private val scope: CoroutineScope, + private val onRumor: ConcordRumorSink = { _, _, _ -> }, ) { - val registry = ConcordSessionRegistry() + val registry = ConcordSessionRegistry(onRumor) private val _revision = MutableStateFlow(0) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index f4d6f7ab54..275907af8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -44,7 +44,9 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey * channel-plane addresses — exactly the `authors` set a subscription must watch * for kind-1059 wraps. Thread-safe: the ingest path and UI share one instance. */ -class ConcordSessionRegistry { +class ConcordSessionRegistry( + private val onRumor: ConcordRumorSink = { _, _, _ -> }, +) { private val lock = KmpLock() // communityId -> live folding session. Insertion-ordered for stable iteration. @@ -68,7 +70,7 @@ class ConcordSessionRegistry { val created = mutableSetOf() for ((id, entry) in wanted) { if (id !in sessions) { - sessions[id] = ConcordCommunitySession(entry, myPubKey) + sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) created += id } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 5d73a6b492..4efcfede84 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -49,7 +49,8 @@ class ConcordCommunitySessionTest { name = "Nostrichs", ) - val session = ConcordCommunitySession(entry, owner.pubKey) + val captured = mutableListOf>() + val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) } assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress) // Feed the genesis control wraps → state folds, channels + membership resolve. @@ -63,13 +64,13 @@ class ConcordCommunitySessionTest { val general = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) assertTrue(session.channelAddresses().contains(general.publicKeyHex)) - // A channel message wrap routes to #general's flow. + // A channel message wrap decrypts and is emitted to the sink for #general. val msgWrap = ConcordActions.buildChannelMessage(owner, general, community.generalChannelIdHex, community.rootEpoch, "gm all", 2L) assertTrue(session.ingest(msgWrap)) - val msgs = session.messagesFlow(community.generalChannelIdHex).value - assertEquals(1, msgs.size) - assertEquals("gm all", msgs[0].content) - assertEquals(owner.pubKey, msgs[0].author) + val general9 = captured.filter { it.second == community.generalChannelIdHex && it.third.content == "gm all" } + assertEquals(1, general9.size) + assertEquals(community.communityIdHex, general9[0].first) + assertEquals(owner.pubKey, general9[0].third.pubKey) // A stray wrap from a different community is ignored. val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt index 29b600f620..f9cc66eecf 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -57,7 +57,8 @@ class ConcordSessionRegistryTest { val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) val beta = ConcordCommunityFactory.create(owner, "Beta", createdAt = 1L, relays = listOf("wss://r.example")) - val registry = ConcordSessionRegistry() + val captured = mutableListOf>() + val registry = ConcordSessionRegistry { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) } // First sync creates a session for each joined community. val created = registry.sync(listOf(entryFor(alpha, "Alpha"), entryFor(beta, "Beta")), owner.pubKey) @@ -78,16 +79,11 @@ class ConcordSessionRegistryTest { val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) assertTrue(registry.subscribeAddresses().contains(general.publicKeyHex)) - // A channel message routes to Alpha's #general flow, not Beta. + // A channel message decrypts and is emitted to the sink for Alpha's #general. val msg = ConcordActions.buildChannelMessage(owner, general, alpha.generalChannelIdHex, alpha.rootEpoch, "gm", 2L) assertTrue(registry.ingest(msg)) - assertEquals( - 1, - registry - .sessionFor(alpha.communityIdHex)!! - .messagesFlow(alpha.generalChannelIdHex) - .value.size, - ) + val general9 = captured.filter { it.first == alpha.communityIdHex && it.second == alpha.generalChannelIdHex && it.third.content == "gm" } + assertEquals(1, general9.size) // A re-sync that keeps Alpha but drops Beta preserves Alpha's folded state and removes Beta. val createdAgain = registry.sync(listOf(entryFor(alpha, "Alpha")), owner.pubKey) From e85de73e603262e6743a9eedee2ec88247fb521b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:20:07 +0000 Subject: [PATCH 034/115] feat(concord): plane-wrapped reactions and replies (outbound) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 45 +++++++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 9 +++ .../concord/ConcordChannelScreen.kt | 69 ++++++++++++++----- .../commons/actions/ConcordActions.kt | 28 ++++++++ .../concord/ConcordCommunitySessionTest.kt | 15 ++++ .../concord/cord03Channels/ChannelChat.kt | 53 ++++++++++++++ 6 files changed, 197 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 34279de5e5..02abc641f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 ─────────────────────────────────────────── diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9eb163a241..0672eacf9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 5b8e0d98c4..3f430869ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -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(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, + ) + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index e509371c9b..83a8e116f0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -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). diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 4efcfede84..62b3dfba2c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -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")) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 2aa2eec960..10f2feae47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -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) From 3895dbe11530a255454fb1a02c24180635e89b2f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:41:16 +0000 Subject: [PATCH 035/115] feat(concord): create-community + invite UI, Concord hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the create/invite Account actions (which had no UI) into screens: - ConcordCreateScreen: name/about/relays form → createConcordCommunity → opens the new community. - ConcordHomeScreen (Route.Concords): the Concord Channels hub — lists joined communities with a Create FAB. Documents that Concord has no public directory (E2E-encrypted, invite-gated), so there is intentionally no browse feed. - ConcordChannelListScreen: an "invite people" top-bar action mints a kind-33301 bundle and shows the shareable link with copy-to-clipboard. - Reachable from the Messages new-chat FAB menu. Note on the planned discovery feed: the kind-33301 bundle exposes only `["d",""],["vsk","6"]` publicly; name/relays/secrets are NIP-44-encrypted under the per-invite token, and it is signed by an ephemeral link key — so there is no public metadata to browse or filter. A GitRepositories-style discovery feed is not applicable; discovery is invite-link-only by design. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/navigation/AppNavigation.kt | 6 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../concord/ConcordChannelListScreen.kt | 62 +++++++++ .../concord/ConcordCreateScreen.kt | 130 ++++++++++++++++++ .../concord/ConcordHomeScreen.kt | 113 +++++++++++++++ .../loggedIn/chats/rooms/ChannelFabColumn.kt | 19 +++ amethyst/src/main/res/values/strings.xml | 9 ++ 7 files changed, 341 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6e452e2da6..f9fc50973c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -102,6 +102,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen @@ -614,6 +616,10 @@ fun BuildNavigation( ) } + composableFromEnd { ConcordHomeScreen(accountViewModel, nav) } + + composableFromEnd { ConcordCreateScreen(accountViewModel, nav) } + composableFromEndArgs { RelayGroupMembersScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 3f7c3a3723..89b424cc7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -690,6 +690,8 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable object ConcordCreate : Route() + // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. @Serializable data class ConcordInvite( val link: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index a8e197b529..76e98fa168 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -27,26 +27,34 @@ 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.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.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -68,6 +76,14 @@ fun ConcordChannelListScreen( val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) .collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var inviteLink by remember { mutableStateOf(null) } + var minting by remember { mutableStateOf(false) } + + inviteLink?.let { link -> + InviteLinkDialog(link = link, onDismiss = { inviteLink = null }) + } + Scaffold( topBar = { TopAppBar( @@ -77,6 +93,20 @@ fun ConcordChannelListScreen( SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) } }, + actions = { + IconButton( + enabled = !minting, + onClick = { + minting = true + scope.launch { + inviteLink = account.mintConcordInvite(communityId) + minting = false + } + }, + ) { + SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action)) + } + }, ) }, ) { padding -> @@ -102,3 +132,35 @@ fun ConcordChannelListScreen( } } } + +/** Shows a freshly minted invite link with a copy-to-clipboard action. */ +@Composable +private fun InviteLinkDialog( + link: String, + onDismiss: () -> Unit, +) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_title)) }, + text = { + Text( + text = link, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + }, + confirmButton = { + TextButton(onClick = { + scope.launch { clipboard.setText(link) } + onDismiss() + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.copy_to_clipboard)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt new file mode 100644 index 0000000000..aa14a4b318 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -0,0 +1,130 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Create a new Concord Channel (encrypted community) from Amethyst. Mints the + * genesis (metadata + #general), publishes it to the given relays (or the + * account's outbox by default), joins it, and opens the new community. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordCreateScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var name by remember { mutableStateOf("") } + var about by remember { mutableStateOf("") } + var relaysCsv by remember { mutableStateOf("") } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title), fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_name)) }, + ) + OutlinedTextField( + value = about, + onValueChange = { about = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_about)) }, + ) + OutlinedTextField( + value = relaysCsv, + onValueChange = { relaysCsv = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays)) }, + placeholder = { Text("wss://relay.one, wss://relay.two") }, + ) + Button( + onClick = { + if (name.isBlank() || working) return@Button + working = true + scope.launch { + val relays = relaysCsv.split(",").map { it.trim() }.filter { it.isNotEmpty() } + val communityId = accountViewModel.account.createConcordCommunity(name.trim(), about.trim().ifBlank { null }, relays) + working = false + if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) + } + }, + enabled = name.isNotBlank() && !working, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt new file mode 100644 index 0000000000..4df6224b67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -0,0 +1,113 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +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.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The Concord Channels hub: lists the communities the account has joined (from the + * kind-13302 list) and offers a Create action. Concord has no public directory — + * communities are E2E-encrypted and invite-gated by design — so there is no browse + * feed here; you arrive at a community by creating one or redeeming an invite link. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordHomeScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val communities by accountViewModel.account.concordChannelList.liveCommunities + .collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title), fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }) { + SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title)) + } + }, + ) { padding -> + if (communities.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_home_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(communities, key = { it.id }) { entry -> + Column( + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.ConcordServer(entry.id)) } + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + Text( + entry.name.ifBlank { stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title) }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + HorizontalDivider() + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index d9eacb8d29..8231d83be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -140,6 +140,25 @@ fun ChannelFabColumn(nav: INav) { } Spacer(modifier = Modifier.height(20.dp)) + + FloatingActionButton( + onClick = { + nav.nav(Route.Concords) + isOpen = false + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Text( + text = stringRes(R.string.concord_home_title), + color = Color.White, + textAlign = TextAlign.Center, + fontSize = Font12SP, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 74ea51703e..f755e1d923 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -307,6 +307,15 @@ Loading account Redeeming invite… Could not fetch this invite. The link may be expired or its relays unreachable. + Concord Channels + You haven\'t joined any Concord Channels yet. Create one, or open an invite link. + New Concord Channel + Name + About (optional) + Relays (comma-separated, optional) + Create + Invite people + Invite link encrypted legacy Looking for the original message… From 441413ea95781e0318513a51d83958e2284c1960 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:56:06 +0000 Subject: [PATCH 036/115] feat(concord): roles & moderation write path (CORD-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcordModeration builds the Control Plane editions for defining a role, granting roles to a member, and banning/unbanning — each a kind-3308 edition, plaintext-sealed and wrapped on the Control Plane. It chains the next version onto the entity's current head (version+1, prevHash=head.hash) and unions the banlist, taking the community's current editions as input. Authority is enforced at fold time by the AuthorityResolver, not here: a round- trip test confirms an owner can define an Admin role, grant it (member gains BAN), ban/unban (version chaining heals), and that a grant forged by a non-outranking troll is dropped by the fold. - ConcordActions.controlEditions opens control wraps into editions - ConcordCommunitySession exposes controlEditions()/controlPlaneKey() so edits chain onto live state - Account.grantConcordRole / banConcordMember / unbanConcordMember publish the edition with an instant local echo (the session refolds it) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 43 +++++ .../commons/actions/ConcordActions.kt | 17 +- .../commons/actions/ConcordModeration.kt | 162 ++++++++++++++++++ .../model/concord/ConcordCommunitySession.kt | 7 + .../commons/actions/ConcordModerationTest.kt | 90 ++++++++++ 5 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 02abc641f1..cfc404698e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount @@ -1652,6 +1653,48 @@ class Account( if (relays.isNotEmpty()) client.publish(wrap, relays) } + // ── Concord roles & moderation (CORD-04) ───────────────────────────────── + // Each publishes a Control Plane edition; authority is enforced at fold time by + // every client's AuthorityResolver, so a call by someone who doesn't outrank the + // target is simply dropped on fold. Owner-authored calls always take effect. + + /** Grant [member] exactly [roleIds] (empty list revokes their roles). */ + suspend fun grantConcordRole( + communityId: String, + member: HexKey, + roleIds: List, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Add [member] to the community banlist. */ + suspend fun banConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Remove [member] from the community banlist. */ + suspend fun unbanConcordMember( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 83a8e116f0..cd684d473a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -101,18 +101,21 @@ object ConcordActions { relays: List = emptyList(), ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays) + /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ + fun controlEditions( + wraps: List, + controlPlane: GroupKey, + ): List = + wraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } + } + /** Opens the control-plane [wraps] and folds them into the live community state. */ fun foldCommunity( wraps: List, controlPlane: GroupKey, ownerPubKey: HexKey, - ): ConcordCommunityState { - val editions = - wraps.mapNotNull { wrap -> - ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } - } - return ConcordCommunityState.fold(editions, ownerPubKey) - } + ): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey) // ---- channel chat --------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt new file mode 100644 index 0000000000..b576928541 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -0,0 +1,162 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer + +/** + * Builds the Control Plane editions (CORD-04) that drive roles and moderation: + * defining a role, granting roles to a member, and banning/unbanning members. + * + * Each is a kind-3308 edition, plaintext-sealed (so the author signature survives + * re-encryption across epochs) and wrapped on the community's Control Plane. The + * caller passes the community's **current** editions so this can chain the next + * version onto the entity's head (`version = head.version + 1`, `prevHash = + * head.hash`) and union the banlist. Authority is enforced at *fold* time by the + * `AuthorityResolver`, not here — an edition whose author doesn't outrank its + * target (or trace to the owner via [citation]) is simply dropped by every client. + * + * The owner needs no [citation]; a delegated moderator must cite the grant they + * act under so the fold can verify the chain terminates at the owner. + */ +object ConcordModeration { + /** version/prevHash to chain onto the current head of ([kind], [entityId]), or genesis. */ + private fun versioning( + current: List, + kind: ControlEntityKind, + entityId: ByteArray, + ): Pair { + val head = current.firstOrNull { it.entityKind == kind && it.entityId.contentEquals(entityId) } + return if (head != null) (head.version + 1) to head.hash else 0L to null + } + + private suspend fun wrap( + actor: NostrSigner, + controlPlane: GroupKey, + kind: ControlEntityKind, + entityId: ByteArray, + version: Long, + prevHash: ByteArray?, + content: String, + createdAt: Long, + citation: AuthorityCitation?, + ): Event { + val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation) + return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt) + } + + /** + * Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id + * — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. + */ + suspend fun defineRole( + actor: NostrSigner, + controlPlane: GroupKey, + roleId: ByteArray, + role: RoleEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.ROLE, roleId) + val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role) + return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) + } + + /** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */ + suspend fun grant( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + roleIds: List, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray()) + val (version, prev) = versioning(current, ControlEntityKind.GRANT, entityId) + val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds)) + return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) + } + + /** Adds [member] to the banlist (union with the current head). */ + suspend fun ban( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) + member.lowercase(), current, createdAt, citation) + + /** Removes [member] from the banlist. */ + suspend fun unban( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + member: HexKey, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId) - member.lowercase(), current, createdAt, citation) + + /** The current banlist union across the head editions (lowercase hex). */ + fun currentBanned( + current: List, + communityId: ByteArray, + ): Set { + val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) + val head = current.firstOrNull { it.entityKind == ControlEntityKind.BANLIST && it.entityId.contentEquals(entityId) } + return head?.let { ConcordJson.decodeBanlist(it.content) }?.mapTo(HashSet()) { it.lowercase() } ?: emptySet() + } + + private suspend fun setBanlist( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + banned: Set, + current: List, + createdAt: Long, + citation: AuthorityCitation?, + ): Event { + val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) + val (version, prev) = versioning(current, ControlEntityKind.BANLIST, entityId) + val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted()) + return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 15ee3779ff..f8e9e6b510 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -83,6 +84,12 @@ class ConcordCommunitySession( /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + /** The community's current Control Plane editions — the input a moderation edition chains onto. */ + fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } + + /** The Control Plane key, for authoring moderation editions. */ + fun controlPlaneKey(): GroupKey = controlPlaneKey + /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { val s = _state.value ?: return ConcordMembership.MEMBER diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt new file mode 100644 index 0000000000..557ff27e55 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -0,0 +1,90 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordModerationTest { + private val owner = NostrSignerInternal(KeyPair()) + private val admin = NostrSignerInternal(KeyPair()) + private val troll = NostrSignerInternal(KeyPair()) + + @Test + fun ownerDefinesRoleGrantsItAndBans() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val communityId = community.communityId + + // Accumulate the community's editions as we publish more. + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: com.vitorpamplona.quartz.nip01Core.core.Event) { + editions += ConcordActions.controlEditions(listOf(wrap), cp) + } + + // Owner defines an "Admin" role (position 1) that can BAN and KICK. + val roleId = ByteArray(32) { (it + 1).toByte() } + val roleIdHex = roleId.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } + val adminRole = + RoleEntity( + name = "Admin", + position = 1, + permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(), + ) + add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L)) + + // Owner grants that role to the admin user. + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L)) + + // Owner bans the troll. + add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L)) + + val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey) + + // The role exists, the admin holds BAN, and the troll is banned. + assertTrue(state.roles.containsKey(roleIdHex)) + assertTrue(state.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) + assertTrue(state.authority.isBanned(troll.pubKey)) + assertFalse(state.authority.isBanned(admin.pubKey)) + + // Unbanning the troll clears the flag (version chains onto the ban). + add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L)) + val healed = ConcordCommunityState.fold(editions, community.ownerPubKey) + assertFalse(healed.authority.isBanned(troll.pubKey)) + + // A grant forged by the troll (who outranks nobody) is dropped by the fold. + val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L) + val forgedEditions: List = editions + ConcordActions.controlEditions(listOf(forged), cp) + val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey) + assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN)) + } +} From b83ef20d61ba6264416947851706b7a2a268785d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:00:12 +0000 Subject: [PATCH 037/115] feat(cli): amy concord roles/role/grant/ban/unban (CORD-04 moderation) Adds the moderation verbs over the tested ConcordModeration write path: - roles list live roles + current banlist - role PERM... define a role (perms by name), prints role_id - grant grant a role to a member - ban / unban banlist add/remove Each drains the Control Plane, chains the next edition onto the current head, and publishes it; authority is enforced on fold by every client. Users resolve via npub/nprofile/hex/nip05 (ctx.requireUserHex). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/cli/commands/ConcordCommands.kt | 7 +- .../cli/commands/ConcordModCommands.kt | 173 ++++++++++++++++++ 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 568593e3ce..ce377b5992 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -45,7 +45,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", mapOf( "create" to { rest -> create(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, @@ -54,6 +54,11 @@ object ConcordCommands { "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, + "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, + "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, + "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, + "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, + "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, ), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt new file mode 100644 index 0000000000..35e186da8e --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -0,0 +1,173 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.stores.ConcordStore +import com.vitorpamplona.amethyst.cli.stores.StoredCommunity +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** `amy concord roles|role|grant|ban|unban` — Control Plane roles & moderation (CORD-04). */ +object ConcordModCommands { + /** Lists the community's live roles and current banlist. */ + suspend fun roles( + dataDir: DataDir, + rest: Array, + ): Int { + val handle = Args(rest).positional(0, "community") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val (_, editions) = load(ctx, sc) + val state = ConcordCommunityState.fold(editions, sc.owner) + Output.emit( + mapOf( + "roles" to + state.roles.map { (id, r) -> + mapOf("id" to id, "name" to r.name, "position" to r.position, "permissions" to r.permissions) + }, + "banned" to ConcordModeration.currentBanned(editions, sc.communityId.hexToByteArray()).toList(), + ), + ) + return 0 + } + } + + /** Defines a new role: `role PERM...` (perms by name, e.g. BAN KICK). */ + suspend fun defineRole( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val name = args.positional(1, "name") + val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 } + val permBits = args.positional.drop(3).mapNotNull { permByName(it) } + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val (cp, editions) = load(ctx, sc) + val roleId = RandomInstance.bytes(32) + val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) + val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now()) + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position, "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Grants a role to a member: `grant `. */ + suspend fun grant( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val userRef = args.positional(1, "user") + val roleId = args.positional(2, "roleId") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val member = ctx.requireUserHex(userRef) + val (cp, editions) = load(ctx, sc) + val wrap = ConcordModeration.grant(ctx.signer, cp, sc.communityId.hexToByteArray(), member, listOf(roleId), editions, TimeUtils.now()) + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("member" to member, "roles" to listOf(roleId), "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Bans a member: `ban `. */ + suspend fun ban( + dataDir: DataDir, + rest: Array, + ): Int = banOrUnban(dataDir, rest, ban = true) + + /** Unbans a member: `unban `. */ + suspend fun unban( + dataDir: DataDir, + rest: Array, + ): Int = banOrUnban(dataDir, rest, ban = false) + + private suspend fun banOrUnban( + dataDir: DataDir, + rest: Array, + ban: Boolean, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val userRef = args.positional(1, "user") + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val member = ctx.requireUserHex(userRef) + val (cp, editions) = load(ctx, sc) + val cid = sc.communityId.hexToByteArray() + val wrap = + if (ban) { + ConcordModeration.ban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + } else { + ConcordModeration.unban(ctx.signer, cp, cid, member, editions, TimeUtils.now()) + } + val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + Output.emit(mapOf("member" to member, "banned" to ban, "published_to" to acked.map { it.url })) + return 0 + } + } + + /** Drain the control plane and return its key + current editions to chain onto. */ + private suspend fun load( + ctx: Context, + sc: StoredCommunity, + ): Pair> { + val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }).map { it.second } + return cp to ConcordActions.controlEditions(wraps, cp) + } + + private fun permByName(name: String): Int? = + when (name.uppercase()) { + "MANAGE_ROLES" -> ConcordPermissions.MANAGE_ROLES + "MANAGE_CHANNELS" -> ConcordPermissions.MANAGE_CHANNELS + "MANAGE_METADATA" -> ConcordPermissions.MANAGE_METADATA + "KICK" -> ConcordPermissions.KICK + "BAN" -> ConcordPermissions.BAN + "MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES + "CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE + else -> null + } +} From 6aa3e0e1b9e60fa88bfbe0ee5dd3e01a616a6ac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:14:07 +0000 Subject: [PATCH 038/115] =?UTF-8?q?feat(concord):=20mobile=20moderation=20?= =?UTF-8?q?=E2=80=94=20ban=20action=20+=20read-time=20ban=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the CORD-04 moderation reachable and enforced on the phone: - A "Ban" action in the note quick-action menu for Concord messages, shown only when this account may actually ban the author (owner or holds BAN, target is not the owner/self — Account.concordBanTarget). Confirms, then publishes the banlist edition via banConcordMember. - Read-time enforcement: Account.isAcceptable drops a Concord message whose author is banned in that community's fold, so banned content is hidden across the inbox and chat feed (filter, not delete — matching how the app handles mutes/blocks). - Reactive: the decrypt sink is gated to drop a banned author's NEW messages before they become Notes, and on re-fold (a ban that lands after messages loaded) refreshConcordChannelIndex removes their existing notes — removeNote invalidates the feed so the ban shows live. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 68 ++++++++++++++++++- .../amethyst/ui/note/NoteQuickActionMenu.kt | 35 ++++++++++ .../ui/screen/loggedIn/AccountViewModel.kt | 6 ++ .../datasource/ConcordChannelSubscription.kt | 11 ++- amethyst/src/main/res/values/strings.xml | 3 + 5 files changed, 119 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index cfc404698e..c3608bcadc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -131,6 +131,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -400,7 +401,29 @@ class Account( * [concordChannelList] and consulted by the giftwrap decrypt path so a Concord * plane wrap routes here instead of being dropped as an undecryptable DM. */ - val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, cache::consumeConcordRumor) + val concordSessions = ConcordSessionManager(concordChannelList.liveCommunities, signer.pubKey, scope, ::consumeConcordRumorGated) + + /** + * Sink for decrypted Concord rumors: drops a message whose author is banned in + * the community's current fold before it ever becomes a Note, then delegates to + * the cache. Bans that arrive *after* a message are handled by removing the + * author's existing notes on re-fold (see `refreshConcordChannelIndex`); this + * gate stops *new* posts from a banned author from appearing at all. + */ + private fun consumeConcordRumorGated( + communityId: String, + channelIdHex: String, + rumor: Event, + ) { + val authority = + concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority + if (authority?.isBanned(rumor.pubKey) == true) return + cache.consumeConcordRumor(communityId, channelIdHex, rumor) + } val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) @@ -1671,6 +1694,29 @@ class Account( return true } + /** + * If [note] is a Concord channel message whose author this account is allowed to + * ban — the actor is the owner or holds the BAN permission, and the target is + * neither the owner nor the actor — returns `(communityId, memberHex)`. Null + * otherwise, so the UI shows the Ban action only when it would actually take + * effect on fold. + */ + fun concordBanTarget(note: Note): Pair? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == signer.pubKey) return null + val communityId = channel.channelId.communityId + val authority = + concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority ?: return null + if (authority.isOwner(author)) return null + val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) + return if (canBan) communityId to author else null + } + /** Add [member] to the community banlist. */ suspend fun banConcordMember( communityId: String, @@ -3785,7 +3831,27 @@ class Account( return limit > 0 && note.event?.hasMoreHashtagsThan(limit) == true } + /** + * True if [note] is a Concord channel message whose author is banned in that + * community's current fold. Bans are per-community (not global mutes), so they + * are enforced here at read time — the same "filter, don't delete" approach the + * rest of the app uses. A ban that arrives after a message is applied on the + * next feed pass. + */ + private fun isConcordBanned(note: Note): Boolean { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return false + val authority = + concordSessions + .sessionFor(channel.channelId.communityId) + ?.state + ?.value + ?.authority ?: return false + return authority.isBanned(author) + } + override fun isAcceptable(note: Note): Boolean { + if (isConcordBanned(note)) return false val mutedThreads = hiddenUsers.flow.value.mutedThreads if (mutedThreads.isNotEmpty() && mutedThreads.contains(resolveThreadRoot(note))) return false return note.author?.let { isAcceptable(it) } ?: true && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 7d4d6ce281..e1782a14ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -274,6 +274,30 @@ fun CardBody( val isOwnNote = accountViewModel.isLoggedUser(note.author) val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) + // Concord moderation: only present when this account may actually ban the author. + val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null } + val showConcordBanDialog = remember { mutableStateOf(false) } + + if (showConcordBanDialog.value) { + QuickActionAlertDialogOneButton( + title = stringRes(R.string.concord_ban_user_title), + textContent = stringRes(R.string.concord_ban_user_body), + buttonIcon = MaterialSymbols.Gavel, + buttonText = stringRes(R.string.concord_ban_user), + buttonColors = + ButtonDefaults.buttonColors( + containerColor = LightRedColor, + contentColor = Color.White, + ), + onClickDoOnce = { + accountViewModel.banConcordMember(note) + showConcordBanDialog.value = false + onDismiss() + }, + onDismiss = { showConcordBanDialog.value = false }, + ) + } + Column(modifier = Modifier.width(IntrinsicSize.Min)) { Row(modifier = Modifier.height(IntrinsicSize.Min)) { NoteQuickActionItem( @@ -449,6 +473,17 @@ fun CardBody( showReportDialog.value = true } } + + if (canConcordBan) { + VerticalDivider(color = primaryLight) + + NoteQuickActionItem( + MaterialSymbols.Gavel, + stringRes(R.string.concord_ban_user), + ) { + showConcordBanDialog.value = true + } + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0672eacf9a..f0c46aee14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -568,6 +568,12 @@ class AccountViewModel( reactToOrDelete(note, reaction) } + /** Ban the author of a Concord channel message (no-op unless this account may ban them). */ + fun banConcordMember(note: Note) { + val (communityId, member) = account.concordBanTarget(note) ?: return + launchSigner { account.banConcordMember(communityId, member) } + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 50bb7394e8..1491b5befc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -82,9 +82,14 @@ private fun refreshConcordChannelIndex(account: Account) { val communityId = session.entry.id val relays = relaysByCommunity[communityId] ?: emptySet() for (channelIdHex in state.channels.keys) { - LocalCache - .getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) - .updateFrom(state, relays, myPubKey) + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + channel.updateFrom(state, relays, myPubKey) + // A member banned since these notes loaded: drop their messages now (the + // ingest gate stops future ones). removeNote invalidates the feed, so the + // ban is reflected live rather than only on the next feed pass. + channel.notes + .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } + .forEach { channel.removeNote(it) } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f755e1d923..cc0c3f6504 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -316,6 +316,9 @@ Create Invite people Invite link + Ban + Ban from this community? + This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later. encrypted legacy Looking for the original message… From 21c6e60e189a59f1c12d2c2b0090da7edc801acf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 22:41:14 +0000 Subject: [PATCH 039/115] feat(concord): mobile "Make admin" / role toggle Adds an owner-only role toggle to the Concord note quick-action menu, alongside Ban: - Account.concordAdminTarget gates the action to the owner (only rank 0 strictly outranks the position-1 Admin role, as the resolver requires), never the owner's own note or the owner as target, and reports whether the author is already an admin (via the new AuthorityResolver.rolesOf accessor). - makeConcordAdmin mints a default Admin role (all management + moderation permissions, position 1) if the community doesn't have one yet, then grants it; removeConcordAdmin revokes via an empty grant. - The menu item flips between "Make admin" and "Remove admin" and fires toggleConcordAdmin. Extends ConcordModerationTest to cover rolesOf and revoke-via-empty-grant. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 84 +++++++++++++++++++ .../amethyst/ui/note/NoteQuickActionMenu.kt | 16 +++- .../ui/screen/loggedIn/AccountViewModel.kt | 8 ++ amethyst/src/main/res/values/strings.xml | 2 + .../commons/actions/ConcordModerationTest.kt | 9 +- .../concord/cord04Roles/AuthorityResolver.kt | 3 + 6 files changed, 120 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c3608bcadc..9ea2fcf897 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -297,6 +298,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope @@ -319,6 +321,9 @@ import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as gallery private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured" +/** Name of the default Concord community Admin role minted by "Make admin". */ +private const val CONCORD_ADMIN_ROLE = "Admin" + @OptIn(DelicateCoroutinesApi::class) @Stable class Account( @@ -1694,6 +1699,85 @@ class Account( return true } + /** The default community Admin role: position 1, holding every management + moderation permission. */ + private fun concordAdminRole() = + RoleEntity( + name = CONCORD_ADMIN_ROLE, + position = 1, + permissions = + ConcordPermissions + .of( + ConcordPermissions.MANAGE_ROLES, + ConcordPermissions.MANAGE_CHANNELS, + ConcordPermissions.MANAGE_METADATA, + ConcordPermissions.KICK, + ConcordPermissions.BAN, + ConcordPermissions.MANAGE_MESSAGES, + ConcordPermissions.CREATE_INVITE, + ).toWire(), + ) + + /** + * If [note] is a Concord channel message whose author the OWNER may toggle + * "admin" on, returns `(communityId, memberHex, isAlreadyAdmin)`. Only the owner + * qualifies — the Admin role sits at position 1 and the resolver requires the + * granter to *strictly* outrank it, which only the owner (rank 0) does. Null for + * the owner's own note, the owner as target, or a non-owner actor. + */ + fun concordAdminTarget(note: Note): Triple? { + val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null + val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null + if (author == signer.pubKey) return null + val communityId = channel.channelId.communityId + val state = concordSessions.sessionFor(communityId)?.state?.value ?: return null + if (state.authority.isOwner(author) || !state.authority.isOwner(signer.pubKey)) return null + val adminRoleId = + state.roles.entries + .firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + ?.key + val isAdmin = adminRoleId != null && adminRoleId in state.authority.rolesOf(author) + return Triple(communityId, author, isAdmin) + } + + /** Promote [member] to the community Admin role, defining that role first if it doesn't exist yet. */ + suspend fun makeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val cp = session.controlPlaneKey() + + val existing = + session.state.value + ?.roles + ?.entries + ?.firstOrNull { it.value.name == CONCORD_ADMIN_ROLE && it.value.position == 1L } + val roleIdHex = + existing?.key ?: run { + val roleId = RandomInstance.bytes(32) + val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, roleWrap) + roleId.toHexKey() + } + + val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, grantWrap) + return true + } + + /** Revoke all roles from [member] (demote an admin back to a plain member). */ + suspend fun removeConcordAdmin( + communityId: String, + member: HexKey, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, grantWrap) + return true + } + /** * If [note] is a Concord channel message whose author this account is allowed to * ban — the actor is the owner or holds the BAN permission, and the target is diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index e1782a14ec..d78cb1919d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -274,8 +274,9 @@ fun CardBody( val isOwnNote = accountViewModel.isLoggedUser(note.author) val isFollowingUser = !isOwnNote && accountViewModel.isFollowing(note.author) - // Concord moderation: only present when this account may actually ban the author. + // Concord moderation: only present when this account may actually act. val canConcordBan = remember(note) { accountViewModel.account.concordBanTarget(note) != null } + val concordAdmin = remember(note) { accountViewModel.account.concordAdminTarget(note) } val showConcordBanDialog = remember { mutableStateOf(false) } if (showConcordBanDialog.value) { @@ -474,6 +475,19 @@ fun CardBody( } } + if (concordAdmin != null) { + VerticalDivider(color = primaryLight) + + val isAdmin = concordAdmin.third + NoteQuickActionItem( + MaterialSymbols.Shield, + stringRes(if (isAdmin) R.string.concord_remove_admin else R.string.concord_make_admin), + ) { + accountViewModel.toggleConcordAdmin(note) + onDismiss() + } + } + if (canConcordBan) { VerticalDivider(color = primaryLight) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index f0c46aee14..7d788e1809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -574,6 +574,14 @@ class AccountViewModel( launchSigner { account.banConcordMember(communityId, member) } } + /** Toggle the Admin role on the author of a Concord channel message (owner only). */ + fun toggleConcordAdmin(note: Note) { + val (communityId, member, isAdmin) = account.concordAdminTarget(note) ?: return + launchSigner { + if (isAdmin) account.removeConcordAdmin(communityId, member) else account.makeConcordAdmin(communityId, member) + } + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cc0c3f6504..0358b6f137 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -316,6 +316,8 @@ Create Invite people Invite link + Make admin + Remove admin Ban Ban from this community? This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index 557ff27e55..1b91d1c85a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -70,12 +70,19 @@ class ConcordModerationTest { val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey) - // The role exists, the admin holds BAN, and the troll is banned. + // The role exists, the admin holds BAN + the role id, and the troll is banned. assertTrue(state.roles.containsKey(roleIdHex)) assertTrue(state.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) + assertTrue(roleIdHex in state.authority.rolesOf(admin.pubKey)) assertTrue(state.authority.isBanned(troll.pubKey)) assertFalse(state.authority.isBanned(admin.pubKey)) + // Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions. + add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L)) + val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey) + assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) + assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty()) + // Unbanning the troll clears the flag (version chains onto the ban). add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L)) val healed = ConcordCommunityState.fold(editions, community.ownerPubKey) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index c5084d47cd..e5ed18453d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -52,6 +52,9 @@ class AuthorityResolver private constructor( fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned + /** The role ids a member currently holds (empty for the owner and for plain members). */ + fun rolesOf(pubKey: String): Set = memberRoles[pubKey.lowercase()] ?: emptySet() + /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ fun rank(pubKey: String): Long? { val m = pubKey.lowercase() From bfc77e80f687c9d884a5257e034203ed950c6d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:45:29 +0000 Subject: [PATCH 040/115] feat(concord): rich message composer (@mention search, inline mentions, reply preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bespoke plain-text composer with the app's standard chat input, mirroring MarmotNewMessageViewModel: ConcordNewMessageViewModel drives UserSuggestionState (type @ → avatar + name + NIP-05 dropdown, ranked by people who've posted in the channel), and the field uses ThinPaddingTextField with MentionPreservingInputTransformation + UrlUserTagOutputTransformation (npub mentions render inline as colored @names), DisplayReplyingToNote for the reply preview, and ThinSendButton. Send still routes through the Concord plane wrap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordChannelScreen.kt | 181 ++++++++++-------- .../send/ConcordNewMessageViewModel.kt | 143 ++++++++++++++ 2 files changed, 240 insertions(+), 84 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 3f430869ad..4a73b11278 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -20,57 +20,63 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord -import androidx.compose.foundation.layout.Box +import android.widget.Toast import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -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.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -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.actions.MentionPreservingInputTransformation +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat +import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** * The chat screen of one Concord Channel. Messages are real Notes in [LocalCache] - * attached to the channel (landed on decrypt by - * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager]), so the - * feed reuses the shared [RefreshingChatroomFeedView] — reactions, replies, zaps - * and OTS render exactly as in every other chat. - * - * Only the send path is Concord-specific: it derives the channel plane key and - * publishes an encrypted wrap to the community relays - * ([com.vitorpamplona.amethyst.model.Account.sendConcordChannelMessage]). - * [ConcordChannelSubscription] keeps the channel's plane live while foregrounded. + * attached to the channel, so the feed reuses the shared [RefreshingChatroomFeedView] + * (avatars, reactions, replies, zaps, OTS) and the composer reuses the same + * @-mention / inline-mention / reply machinery as the other chats. Only the send + * path is Concord-specific: it wraps the message on the channel plane. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -92,9 +98,9 @@ fun ConcordChannelScreen( ) WatchLifecycleAndUpdateModel(feedViewModel) - val scope = rememberCoroutineScope() - var draft by remember { mutableStateOf("") } - var replyTo by remember { mutableStateOf(null) } + val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") + newMessageModel.init(accountViewModel) + newMessageModel.load(communityId, channelId) Scaffold( topBar = { @@ -115,34 +121,25 @@ fun ConcordChannelScreen( ) }, ) { padding -> - Column(Modifier.fillMaxSize().padding(padding).imePadding()) { - Column(Modifier.weight(1f).fillMaxWidth()) { + Column(Modifier.fillMaxHeight().padding(padding)) { + Column(Modifier.fillMaxHeight().weight(1f, true)) { RefreshingChatroomFeedView( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, routeForLastRead = "Concord/$communityId/$channelId", - onWantsToReply = { replyTo = it }, + onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, ) } if (channel.canPost()) { - ConcordComposer( - draft = draft, - replyingTo = replyTo, + Spacer(modifier = DoubleVertSpacer) + ConcordMessageComposer( + newMessageModel = newMessageModel, accountViewModel = accountViewModel, - onDraftChange = { draft = it }, - onCancelReply = { replyTo = null }, - onSend = { - val text = draft.trim() - if (text.isNotEmpty()) { - val parent = replyTo - draft = "" - replyTo = null - scope.launch { account.sendConcordChannelMessage(communityId, channelId, text, parent) } - } - }, + nav = nav, + onMessageSent = { feedViewModel.feedState.sendToTop() }, ) } } @@ -150,53 +147,69 @@ fun ConcordChannelScreen( } @Composable -private fun ConcordComposer( - draft: String, - replyingTo: Note?, +private fun ConcordMessageComposer( + newMessageModel: ConcordNewMessageViewModel, accountViewModel: AccountViewModel, - onDraftChange: (String) -> Unit, - onCancelReply: () -> Unit, - onSend: () -> Unit, + nav: INav, + onMessageSent: suspend () -> Unit, ) { - 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, + val scope = rememberCoroutineScope() + val canPost by remember { derivedStateOf { newMessageModel.canPost() } } + val context = LocalContext.current + + DisposableEffect(newMessageModel.channelId) { + onDispose { newMessageModel.userSuggestions?.reset() } + } + + newMessageModel.replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() } + } + + Column(modifier = EditFieldModifier) { + newMessageModel.userSuggestions?.let { + ShowUserSuggestionList( + it, + newMessageModel::autocompleteWithUser, + accountViewModel, + SuggestionListDefaultHeightChat, ) - 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, - ) - } - } } + + ThinPaddingTextField( + state = newMessageModel.message, + onTextChanged = { newMessageModel.onMessageChanged() }, + inputTransformation = MentionPreservingInputTransformation, + outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(com.vitorpamplona.amethyst.R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + scope.launch(Dispatchers.IO) { + try { + newMessageModel.sendPost() + onMessageSent() + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt new file mode 100644 index 0000000000..0269a87713 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -0,0 +1,143 @@ +/* + * 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.send + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Composition state for the Concord channel message field, mirroring the other + * chat composers (MarmotNewMessageViewModel, ChannelNewMessageViewModel): + * @-mention user suggestions (avatar + name + NIP-05 dropdown) and reply state. + * Sending routes through [Account.sendConcordChannelMessage], which wraps the + * message on the channel plane. The typed text already carries `nostr:npub…` + * mentions inline (rewritten by [UserSuggestionState.replaceCurrentWord]). + */ +@Stable +open class ConcordNewMessageViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + lateinit var account: Account + + var communityId: HexKey? = null + var channelId: HexKey? = null + + val message = TextFieldState() + val replyTo = mutableStateOf(null) + + var userSuggestions: UserSuggestionState? = null + + open fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + this.account = accountVM.account + + this.userSuggestions?.reset() + this.userSuggestions = + UserSuggestionState( + accountVM.account, + accountVM.nip05ClientBuilder(), + // Rank people who have posted in this channel first. + priorityPubkeys = { channelAuthors() }, + ) + } + + private fun channelAuthors(): Set { + val community = communityId ?: return emptySet() + val channel = channelId ?: return emptySet() + return LocalCache + .getConcordChannelIfExists(ConcordChannelId(community, channel)) + ?.notes + ?.mapNotNull { _, note -> note.author?.pubkeyHex } + ?.toSet() + ?: emptySet() + } + + open fun load( + communityId: HexKey, + channelId: HexKey, + ) { + if (this.communityId != communityId || this.channelId != channelId) { + this.communityId = communityId + this.channelId = channelId + this.message.clearText() + this.replyTo.value = null + } + } + + fun reply(note: Note) { + replyTo.value = note + } + + fun clearReply() { + replyTo.value = null + } + + fun editFromDraft(draftMessage: String) { + message.setTextAndPlaceCursorAtEnd(draftMessage) + } + + fun canPost() = message.text.isNotBlank() + + fun onMessageChanged() { + if (message.selection.collapsed) { + val lastWord = message.currentWord() + if (lastWord.startsWith("@")) { + userSuggestions?.processCurrentWord(lastWord) + } else { + userSuggestions?.reset() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestions?.let { + it.replaceCurrentWord(message, message.currentWord(), item) + it.reset() + } + } + + /** Sends the field's text as a channel message (or a reply). Throws on failure. */ + suspend fun sendPost() { + val community = communityId ?: return + val channel = channelId ?: return + val text = message.text.toString().trim() + if (text.isEmpty()) return + + val parent = replyTo.value + account.sendConcordChannelMessage(community, channel, text, parent) + + message.clearText() + replyTo.value = null + userSuggestions?.reset() + } +} From f7ee2964db09cf581ddc0186a76848c2aee57495 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:49:04 +0000 Subject: [PATCH 041/115] feat(concord): create screen uses the relay picker + icon field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the comma-separated relay text box with the app's RelayUrlEditField (type a relay → NIP-11 name/icon autocomplete, tap to add), shown above a removable list of chosen relays. Adds an optional community icon URL, plumbed through createConcordCommunity → ConcordActions.createCommunity → ConcordCommunityFactory into MetadataEntity.icon. Section header styling matches RelayGroupMetadataScreen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 3 +- .../concord/ConcordCreateScreen.kt | 63 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 3 +- .../commons/actions/ConcordActions.kt | 3 +- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 9ea2fcf897..eae2a2b5fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1530,10 +1530,11 @@ class Account( name: String, description: String? = null, relays: List = emptyList(), + icon: String? = null, ): String? { if (!isWriteable()) return null val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } - val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls) + val community = ConcordActions.createCommunity(signer, name, TimeUtils.now(), description, relayUrls, icon) val publishTo = relayUrls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { outboxRelays.flow.value } community.genesisWraps.forEach { client.publish(it, publishTo) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index aa14a4b318..621e692d9e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -30,8 +31,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -40,6 +43,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -47,13 +52,16 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** * Create a new Concord Channel (encrypted community) from Amethyst. Mints the - * genesis (metadata + #general), publishes it to the given relays (or the + * genesis (metadata + #general), publishes it to the chosen relays (or the * account's outbox by default), joins it, and opens the new community. */ @OptIn(ExperimentalMaterial3Api::class) @@ -64,7 +72,8 @@ fun ConcordCreateScreen( ) { var name by remember { mutableStateOf("") } var about by remember { mutableStateOf("") } - var relaysCsv by remember { mutableStateOf("") } + var iconUrl by remember { mutableStateOf("") } + val relays = remember { mutableListOf().toMutableStateList() } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -103,28 +112,64 @@ fun ConcordCreateScreen( label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_about)) }, ) OutlinedTextField( - value = relaysCsv, - onValueChange = { relaysCsv = it }, + value = iconUrl, + onValueChange = { iconUrl = it }, modifier = Modifier.fillMaxWidth(), - label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays)) }, - placeholder = { Text("wss://relay.one, wss://relay.two") }, + singleLine = true, + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_icon)) }, + placeholder = { Text("https://…/icon.png") }, ) + + SectionHeader(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays)) + relays.forEach { relay -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = { relays.remove(relay) }) { + SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.remove)) + } + } + } + RelayUrlEditField( + onNewRelay = { if (it !in relays) relays.add(it) }, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) + Button( onClick = { if (name.isBlank() || working) return@Button working = true scope.launch { - val relays = relaysCsv.split(",").map { it.trim() }.filter { it.isNotEmpty() } - val communityId = accountViewModel.account.createConcordCommunity(name.trim(), about.trim().ifBlank { null }, relays) + val communityId = + accountViewModel.account.createConcordCommunity( + name = name.trim(), + description = about.trim().ifBlank { null }, + relays = relays.map { it.url }, + icon = iconUrl.trim().ifBlank { null }, + ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) } }, enabled = name.isNotBlank() && !working, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action)) } } } } + +@Composable +private fun SectionHeader(text: String) { + Surface(color = MaterialTheme.colorScheme.surface) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 03d6b67fd8..138573f3dd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -312,7 +312,8 @@ New Concord Channel Name About (optional) - Relays (comma-separated, optional) + Relays + Icon URL (optional) Create Invite people Invite link diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index cd684d473a..0542dfc199 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -99,7 +99,8 @@ object ConcordActions { createdAt: Long, description: String? = null, relays: List = emptyList(), - ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays) + icon: String? = null, + ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon) /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ fun controlEditions( From 3f475fd5eb3454f67f2ab55385fa32018a205606 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:53:49 +0000 Subject: [PATCH 042/115] feat(concord): rich community/channel rows + inbox community icon - ConcordHomeScreen: each community row now shows a circular avatar (RobohashFallbackAsyncImage, community icon or robohash fallback) and a pluralized channel count, re-read reactively on each Control-Plane fold. - ConcordChannelListScreen: channel rows show a type icon (# public / lock private / mic voice) and an explicit empty state, styled like the relay-group channel list (thin dividers). - Inbox row (ConcordRoomCompose): passes the folded community icon to ChannelName instead of null, so Messages shows the community avatar. - ConcordChannel exposes communityIcon from the folded MetadataEntity. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordChannelListScreen.kt | 53 ++++++++--- .../concord/ConcordHomeScreen.kt | 92 +++++++++++++++---- .../chats/rooms/ChatroomHeaderCompose.kt | 2 +- amethyst/src/main/res/values/strings.xml | 5 + .../commons/model/concord/ConcordChannel.kt | 5 + 5 files changed, 125 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 76e98fa168..fa289044dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -21,10 +21,13 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -42,6 +45,7 @@ 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 @@ -116,18 +120,43 @@ fun ConcordChannelListScreen( ?.entries ?.toList() .orEmpty() - LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(channels, key = { it.key }) { entry -> - val name = entry.value.definition?.name ?: entry.key - Column( - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Concord(communityId, entry.key)) } - .padding(horizontal = 16.dp, vertical = 14.dp), - ) { - Text("# $name", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + if (channels.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_channels_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(channels, key = { it.key }) { entry -> + val def = entry.value.definition + val name = def?.name ?: entry.key + val icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + } + Row( + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Concord(communityId, entry.key)) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) + } + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } - HorizontalDivider() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 4df6224b67..48a9926176 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -38,12 +41,18 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -62,52 +71,97 @@ fun ConcordHomeScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val communities by accountViewModel.account.concordChannelList.liveCommunities - .collectAsStateWithLifecycle() + val account = accountViewModel.account + val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() + // Re-read folded metadata (icon / channel count) whenever a Control Plane folds. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title), fontWeight = FontWeight.Bold) }, + title = { Text(stringRes(R.string.concord_home_title), fontWeight = FontWeight.Bold) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { - SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) } }, ) }, floatingActionButton = { FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }) { - SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title)) + SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.concord_create_title)) } }, ) { padding -> if (communities.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( - stringRes(com.vitorpamplona.amethyst.R.string.concord_home_empty), + stringRes(R.string.concord_home_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), ) } } else { LazyColumn(Modifier.fillMaxSize().padding(padding)) { items(communities, key = { it.id }) { entry -> - Column( - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.ConcordServer(entry.id)) } - .padding(horizontal = 16.dp, vertical = 14.dp), - ) { - Text( - entry.name.ifBlank { stringRes(com.vitorpamplona.amethyst.R.string.concord_home_title) }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - ) - } - HorizontalDivider() + val state = + remember(entry.id, revision) { + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + } + CommunityRow( + communityId = entry.id, + name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, + iconUrl = state?.metadata?.icon, + channelCount = state?.channels?.size ?: 0, + accountViewModel = accountViewModel, + onClick = { nav.nav(Route.ConcordServer(entry.id)) }, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } } } + +@Composable +private fun CommunityRow( + communityId: String, + name: String, + iconUrl: String?, + channelCount: Int, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = + androidx.compose.foundation.layout.Arrangement + .spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = communityId, + model = iconUrl, + contentDescription = name, + modifier = Modifier.size(40.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (channelCount > 0) { + Text( + pluralStringResource(R.plurals.concord_channel_count, channelCount, channelCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 3fa55bed44..383bb3254a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -436,7 +436,7 @@ private fun ConcordRoomCompose( ChannelName( channelIdHex = channel.channelId.channelId, - channelPicture = null, + channelPicture = channel.communityIcon, channelTitle = { modifier -> Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 138573f3dd..d3b7d8eda3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -309,11 +309,16 @@ Could not fetch this invite. The link may be expired or its relays unreachable. Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. + No channels yet. New Concord Channel Name About (optional) Relays Icon URL (optional) + + %1$d channel + %1$d channels + Create Invite people Invite link diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index a19bbe71fb..5854f1fc0c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -60,6 +60,10 @@ class ConcordChannel( var communityName: String? = null private set + /** The parent community's icon URL, from its folded metadata (null if unset). */ + var communityIcon: String? = null + private set + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ var communityRelays: Set = emptySet() private set @@ -84,6 +88,7 @@ class ConcordChannel( isPrivate = it.private } communityName = state.metadata?.name + communityIcon = state.metadata?.icon communityRelays = relays membership = ConcordMembership.of(state.authority, myPubKey) } From bafa0cca9a88ea242ab3a2aa0841265098bb9883 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 23:55:20 +0000 Subject: [PATCH 043/115] feat(concord): invite dialog shows a QR code + share sheet The minted invite link now renders as a scannable QR (QrCodeDrawer) with a system Share action (ACTION_SEND chooser) and a Copy fallback, instead of just copying raw text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordChannelListScreen.kt | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index fa289044dc..5fc7ff6aff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import android.content.Intent import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -48,7 +50,9 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -57,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -162,7 +167,7 @@ fun ConcordChannelListScreen( } } -/** Shows a freshly minted invite link with a copy-to-clipboard action. */ +/** Shows a freshly minted invite link as a QR code with copy + share actions. */ @Composable private fun InviteLinkDialog( link: String, @@ -170,17 +175,38 @@ private fun InviteLinkDialog( ) { val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() + val context = LocalContext.current AlertDialog( onDismissRequest = onDismiss, title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_title)) }, text = { - Text( - text = link, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + QrCodeDrawer(contents = link, modifier = Modifier.size(220.dp)) + Text( + text = link, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp), + ) + } }, confirmButton = { + TextButton(onClick = { + val send = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + context.startActivity(Intent.createChooser(send, stringRes(context, com.vitorpamplona.amethyst.R.string.concord_invite_title))) + onDismiss() + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.quick_action_share)) + } + }, + dismissButton = { TextButton(onClick = { scope.launch { clipboard.setText(link) } onDismiss() @@ -188,8 +214,5 @@ private fun InviteLinkDialog( Text(stringRes(com.vitorpamplona.amethyst.R.string.copy_to_clipboard)) } }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) } - }, ) } From b4fe6dffcdc85ab08a63210e84dc71870938ecb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 00:16:05 +0000 Subject: [PATCH 044/115] refactor(concord): move per-relay filter assembly to shared planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android assembler was hand-rolling the plane-address -> per-relay kind-1059 RelayBasedFilter collapse (with per-relay since). That logic is platform-agnostic — RelayBasedFilter and SincePerRelayMap are both commons/quartz types — so lift it to ConcordSubscriptionPlanner.relayBasedFilters alongside the existing filtersByRelay one-shot variant. The assembler now only does the account-dependent step (deriving channel planes from folded session state) and delegates the collapse. Covered by a new planner unit test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ConcordChannelFilterAssembler.kt | 26 +++----------- .../actions/ConcordSubscriptionPlanner.kt | 35 +++++++++++++++++++ .../actions/ConcordSubscriptionPlannerTest.kt | 32 +++++++++++++++++ 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index 7bcb6f7254..25543b1888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -26,11 +26,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** One screen's request to keep the user's joined Concord Channels live. */ class ConcordChannelQueryState( @@ -76,7 +73,9 @@ class ConcordChannelSubAssembler( if (entries.isEmpty()) return null // Control planes for every joined community, plus channel planes for the - // ones whose Control Plane has already folded. + // ones whose Control Plane has already folded. Deriving the channel planes + // is the only account-dependent step; collapsing planes into per-relay + // kind-1059 filters lives in the shared planner. val subs = ArrayList() subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) for (entry in entries) { @@ -88,24 +87,7 @@ class ConcordChannelSubAssembler( subs += ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) } - // One kind-1059 filter per host relay, carrying every plane address on it. - val authorsByRelay = HashMap>() - for (sub in subs) { - for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) - } - if (authorsByRelay.isEmpty()) return null - - return authorsByRelay.map { (relay, authors) -> - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = listOf(ConcordKinds.WRAP), - authors = authors.toList(), - since = since?.get(relay)?.time, - ), - ) - } + return ConcordSubscriptionPlanner.relayBasedFilters(subs, since) } override fun id(key: ConcordChannelQueryState) = key.account diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 05c9442037..d8dc2db7ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -20,10 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -89,5 +92,37 @@ object ConcordSubscriptionPlanner { return authorsByRelay.mapValues { (_, authors) -> listOf(ConcordActions.planeFilterFor(authors)) } } + /** + * Collapses [subs] into one [RelayBasedFilter] per host relay for a live + * subscription: each relay gets a single `{kinds:[1059], authors:[…all plane + * pks on it…], since}` filter, with [since] applied per relay from the EOSE + * map. Returns null when no plane resolves to a relay (nothing to subscribe). + * + * This is the assembler-facing shape (what a `PerUniqueIdEoseManager` returns); + * [filtersByRelay] is the one-shot drain shape (no `since`). + */ + fun relayBasedFilters( + subs: List, + since: SincePerRelayMap?, + ): List? { + val authorsByRelay = HashMap>() + for (sub in subs) { + for (relay in sub.relays) authorsByRelay.getOrPut(relay) { HashSet() }.add(sub.pubKeyHex) + } + if (authorsByRelay.isEmpty()) return null + + return authorsByRelay.map { (relay, authors) -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordKinds.WRAP), + authors = authors.toList(), + since = since?.get(relay)?.time, + ), + ) + } + } + private fun normalize(urls: List): Set = urls.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index ca70488ff5..82598e6b17 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -20,13 +20,16 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.amethyst.commons.relays.MutableTime import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class ConcordSubscriptionPlannerTest { @@ -71,4 +74,33 @@ class ConcordSubscriptionPlannerTest { assertTrue(filter.authors!!.contains(community.controlPlane.publicKeyHex)) assertTrue(filter.authors!!.contains(general.pubKeyHex)) } + + @Test + fun relayBasedFiltersCollapsePerRelayAndApplySince() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val entry = + com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val subs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) + val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!! + + // One kind-1059 filter for the single relay, carrying the derived since. + val filters = ConcordSubscriptionPlanner.relayBasedFilters(subs, mutableMapOf(relay to MutableTime(1234L)))!! + assertEquals(1, filters.size) + assertEquals(relay, filters[0].relay) + assertEquals(listOf(1059), filters[0].filter.kinds) + assertEquals(1234L, filters[0].filter.since) + assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex)) + + // No planes resolve to a relay -> nothing to subscribe. + assertNull(ConcordSubscriptionPlanner.relayBasedFilters(emptyList(), null)) + } } From 7ebbd2e46e6b16015e2fbf90397ea78d8b279a18 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:08:39 +0000 Subject: [PATCH 045/115] fix(concord): round the create-community FAB to match app convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub's create FAB fell back to Material3's default rounded-square shape; every other FAB in the app (RelayGroupChannelListScreen, MarmotGroupListScreen, NewNoteButton, …) uses shape = CircleShape with a 24dp icon. Match it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../chats/publicChannels/concord/ConcordHomeScreen.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 48a9926176..7db61c725f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -88,8 +88,12 @@ fun ConcordHomeScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }) { - SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(R.string.concord_create_title)) + FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }, shape = CircleShape) { + SymbolIcon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.concord_create_title), + modifier = Modifier.size(24.dp), + ) } }, ) { padding -> From 9771d24cba3694ac4fb8a035ffefdfd7dc17fce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:13:46 +0000 Subject: [PATCH 046/115] fix(concord): fetch the kind-13302 community list at login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account info/lists subscription loaded every other joined-list up front (NIP-29 simple groups, relay lists, wallet, …) but not the Concord joined-communities list, so a community joined on another client sharing the same key (e.g. the Armada reference client) never surfaced: nothing REQ'd kind-13302 for the account, leaving ConcordChannelListState empty until the user created or redeemed an invite inside Amethyst. Add ConcordCommunityListEvent.KIND to the up-front fetch, mirroring the NIP-29 SimpleGroupList rationale already there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../account/metadata/FilterAccountInfoAndListsFromKey.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 28b4f7b4f5..8ee5c6863d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -84,6 +85,12 @@ val AccountInfoAndListsFromKeyKinds2 = // Loaded up-front so "My Groups" and group memberships resolve immediately at login, // without waiting for the groups screen to mount its own subscription. SimpleGroupListEvent.KIND, + // Concord private joined-communities list (kind 13302, CORD-05): the self-encrypted + // entries carrying each community's secrets. Loaded up-front for the same reason as + // the NIP-29 list above — so communities joined on another device or client (e.g. the + // Armada reference client, sharing this key) surface in the Concord hub at login, + // instead of only appearing after creating/redeeming an invite in Amethyst itself. + ConcordCommunityListEvent.KIND, // NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always // useful to have available — wallet event holds the user's P2PK key // + mint list, nutzap info tells other clients which mints to lock From 0fae8b4966ea052fe26f829680cb18de282ede58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:20:42 +0000 Subject: [PATCH 047/115] fix(concord): route taps on a Concord message to its channel RouteMaker resolved Marmot and NIP-29 relay-group channel gatherers to their chat screens but had no ConcordChannel branch, so tapping a Concord message (from the Messages inbox row, a quoted note, go-to-conversation) fell through to the generic thread view instead of opening the channel. Add the branch and a routeFor(ConcordChannel) mirroring routeFor(RelayGroupChannel). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/navigation/routes/RouteMaker.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 579b779d5b..aa713474c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation.routes +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel @@ -84,6 +85,14 @@ fun routeFor( return routeFor(relayGroup) } + // Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real + // Note attached to its ConcordChannel gatherer. Like the relay-group case above, route to the + // Concord channel screen instead of the generic thread view it would otherwise fall through to. + val concordChannel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concordChannel != null) { + return routeFor(concordChannel) + } + val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) if (noteEvent.isGroupScoped()) { @@ -282,6 +291,8 @@ fun routeFor(note: RelayGroupChannel): Route = Route.RelayGroup(note.groupId.id, fun routeFor(groupId: GroupId): Route = Route.RelayGroup(groupId.id, groupId.relayUrl.url) +fun routeFor(channel: ConcordChannel): Route = Route.Concord(channel.channelId.communityId, channel.channelId.channelId) + fun routeFor(user: User): Route.Profile = Route.Profile(user.pubkeyHex) fun routeForUser(userHex: HexKey): Route.Profile = Route.Profile(userHex) From 73c6bd6180430661af32368c4bf9cc8a9dcf32fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 01:50:49 +0000 Subject: [PATCH 048/115] feat(concord): members roster + edit-metadata screens, redesigned create form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two management screens the NIP-29 relay groups have but Concord lacked, plus a visual overhaul of the create screen: - Members roster (ConcordMembersScreen): owner + role-holders + banned, each with avatar/name and owner/admin/banned badge; overflow menu promotes/demotes (owner) and bans/unbans, gated on the viewer's authority. New AuthorityResolver.roleHolders() and bannedMembers() accessors expose the privileged roster (membership is otherwise key possession, so there is no silent-member list). - Edit metadata (ConcordEditScreen): new write path — ConcordModeration.editMetadata (METADATA edition, entityId = communityId) + Account.editConcordMetadata; prefilled from the folded state, honored on fold only for MANAGE_METADATA holders / owner. - Create screen redesign: shared ConcordMetadataFields with a circular icon hero (live preview from the URL) + section headers, mirroring the NIP-29 GroupImagePicker layout, replacing the bare stack of text fields. - Routes ConcordMembers/ConcordEdit + nav registration + top-bar entry points on the channel-list screen (Members always, Edit for those who can manage metadata). - Account.peekConcordInvite for the upcoming invite card. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 38 +++ .../amethyst/ui/navigation/AppNavigation.kt | 18 ++ .../amethyst/ui/navigation/routes/Routes.kt | 8 + .../ui/screen/loggedIn/AccountViewModel.kt | 18 ++ .../concord/ConcordChannelListScreen.kt | 15 ++ .../concord/ConcordCreateScreen.kt | 71 +++-- .../concord/ConcordEditScreen.kt | 153 +++++++++++ .../concord/ConcordMembersScreen.kt | 246 ++++++++++++++++++ .../concord/ConcordMetadataForm.kt | 153 +++++++++++ amethyst/src/main/res/values/strings.xml | 15 ++ .../commons/actions/ConcordModeration.kt | 21 ++ .../concord/cord04Roles/AuthorityResolver.kt | 11 + 12 files changed, 730 insertions(+), 37 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3803c251e3..9e925eab69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -132,7 +132,9 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -1826,6 +1828,42 @@ class Account( return true } + /** + * Replace the community metadata (name / icon / description / relays) with a new + * Control-Plane edition. Honored on fold only when this account holds + * MANAGE_METADATA (or is the owner); dropped otherwise, like every other edition. + */ + suspend fun editConcordMetadata( + communityId: String, + name: String, + description: String?, + icon: String?, + relays: List, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val metadata = MetadataEntity(name = name, icon = icon, description = description, relays = relays) + val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** + * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from + * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT + * joining. Returns the [CommunityInvite] (name, relays, community coordinates) so a + * card can show what the link opens, or null if the link is invalid/unreadable. + */ + suspend fun peekConcordInvite(url: String): CommunityInvite? { + val parsed = ConcordActions.parseInviteLink(url) ?: return null + val relays = + (parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + outboxRelays.flow.value).toSet() + if (relays.isEmpty()) return null + val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } + val wraps = client.fetchAll(filters = filters) + return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + } + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index f9fc50973c..d58c8ad814 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -103,8 +103,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGro import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordChannelScreen 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.ConcordInviteScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.metadata.NewEphemeralChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.PublicChatChannelScreen @@ -608,6 +610,22 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordMembersScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + composableFromEndArgs { + ConcordEditScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordInviteScreen( link = it.link, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 89b424cc7d..b4ab7953bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -690,6 +690,14 @@ sealed class Route { val communityId: String, ) : Route() + @Serializable data class ConcordMembers( + val communityId: String, + ) : Route() + + @Serializable data class ConcordEdit( + val communityId: String, + ) : Route() + @Serializable object ConcordCreate : Route() // Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index aaa2558356..23f5b595d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -584,6 +584,24 @@ class AccountViewModel( } } + /** Promote/demote [member] as an Admin of [communityId] (from the Members roster; owner only takes effect). */ + fun setConcordAdmin( + communityId: String, + member: HexKey, + makeAdmin: Boolean, + ) = launchSigner { + if (makeAdmin) account.makeConcordAdmin(communityId, member) else account.removeConcordAdmin(communityId, member) + } + + /** Ban/unban [member] from [communityId] (from the Members roster). */ + fun setConcordBan( + communityId: String, + member: HexKey, + ban: Boolean, + ) = launchSigner { + if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 5fc7ff6aff..d75ce8185a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -103,6 +104,20 @@ fun ConcordChannelListScreen( } }, actions = { + val canEdit = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA) + } == true + + IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) { + SymbolIcon(symbol = MaterialSymbols.Group, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_members_title)) + } + if (canEdit) { + IconButton(onClick = { nav.nav(Route.ConcordEdit(communityId)) }) { + SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title)) + } + } IconButton( enabled = !minting, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 621e692d9e..d8c680f857 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -32,9 +32,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -70,9 +68,9 @@ fun ConcordCreateScreen( accountViewModel: AccountViewModel, nav: INav, ) { - var name by remember { mutableStateOf("") } - var about by remember { mutableStateOf("") } - var iconUrl by remember { mutableStateOf("") } + val name = remember { mutableStateOf("") } + val about = remember { mutableStateOf("") } + val iconUrl = remember { mutableStateOf("") } val relays = remember { mutableListOf().toMutableStateList() } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -96,31 +94,20 @@ fun ConcordCreateScreen( .padding(padding) .padding(16.dp) .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_name)) }, - ) - OutlinedTextField( - value = about, - onValueChange = { about = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_about)) }, - ) - OutlinedTextField( - value = iconUrl, - onValueChange = { iconUrl = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_icon)) }, - placeholder = { Text("https://…/icon.png") }, + ConcordMetadataFields( + name = name, + about = about, + iconUrl = iconUrl, + robotSeed = "concord-new", + accountViewModel = accountViewModel, ) - SectionHeader(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays)) + ConcordSectionHeader( + title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays), + description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc), + ) relays.forEach { relay -> Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) @@ -138,21 +125,21 @@ fun ConcordCreateScreen( Button( onClick = { - if (name.isBlank() || working) return@Button + if (name.value.isBlank() || working) return@Button working = true scope.launch { val communityId = accountViewModel.account.createConcordCommunity( - name = name.trim(), - description = about.trim().ifBlank { null }, + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, - icon = iconUrl.trim().ifBlank { null }, + icon = iconUrl.value.trim().ifBlank { null }, ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) } }, - enabled = name.isNotBlank() && !working, + enabled = name.value.isNotBlank() && !working, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), ) { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_action)) @@ -161,15 +148,25 @@ fun ConcordCreateScreen( } } +/** A section header (title + one-line description) matching the NIP-29 metadata form. */ @Composable -private fun SectionHeader(text: String) { - Surface(color = MaterialTheme.colorScheme.surface) { +fun ConcordSectionHeader( + title: String, + description: String? = null, +) { + Column(Modifier.fillMaxWidth().padding(top = 4.dp)) { Text( - text = text, - style = MaterialTheme.typography.labelLarge, + text = title, + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(top = 8.dp), ) + description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt new file mode 100644 index 0000000000..3fe74570ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -0,0 +1,153 @@ +/* + * 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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * Edit a Concord community's metadata (name / description / icon). Reuses the shared + * [ConcordMetadataFields] hero + fields, prefilled from the folded Control Plane, and + * saves a new metadata edition via [com.vitorpamplona.amethyst.model.Account.editConcordMetadata] + * — honored on fold only when this account holds MANAGE_METADATA (or is the owner). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordEditScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + + val name = remember { mutableStateOf("") } + val about = remember { mutableStateOf("") } + val iconUrl = remember { mutableStateOf("") } + var prefilled by remember { mutableStateOf(false) } + var working by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Seed the fields once, the first time the folded metadata is available. + LaunchedEffect(state?.metadata) { + val md = state?.metadata + if (!prefilled && md != null) { + name.value = md.name + about.value = md.description.orEmpty() + iconUrl.value = md.icon.orEmpty() + prefilled = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.concord_edit_title), fontWeight = FontWeight.Bold, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + if (session == null) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + ConcordMetadataFields( + name = name, + about = about, + iconUrl = iconUrl, + robotSeed = communityId, + accountViewModel = accountViewModel, + ) + + Button( + onClick = { + if (name.value.isBlank() || working) return@Button + working = true + scope.launch { + val ok = + account.editConcordMetadata( + communityId = communityId, + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, + icon = iconUrl.value.trim().ifBlank { null }, + relays = state?.metadata?.relays ?: session.entry.relays, + ) + working = false + if (ok) nav.popBack() + } + }, + enabled = name.value.isNotBlank() && !working, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text(stringRes(R.string.concord_edit_save)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt new file mode 100644 index 0000000000..5ff00e5e4c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -0,0 +1,246 @@ +/* + * 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.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.foundation.shape.RoundedCornerShape +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.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordMembership +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The members roster of one Concord community — the analog of NIP-29's + * `RelayGroupMembersScreen`. Concord has no relay-signed roster (membership is key + * possession), so this shows the *privileged* roster derivable from the folded + * Control Plane: the owner, every role-holder (admins/moderators), and banned + * users. The overflow menu offers promote/demote (owner) and ban/unban, gated on + * the viewer's authority exactly as the write path enforces on fold. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordMembersScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + + val myPubKey = account.signer.pubKey + val roster = + remember(state) { + val s = state ?: return@remember emptyList() + val authority = s.authority + val pubkeys = (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers()).map { it.lowercase() }.distinct() + pubkeys + .map { RosterEntry(it, ConcordMembership.of(authority, it)) } + .sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) + } + + val iAmOwner = state?.authority?.isOwner(myPubKey) == true + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_members_title), fontWeight = FontWeight.Bold, maxLines = 1) + state?.metadata?.name?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + if (roster.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + stringRes(R.string.concord_members_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 32.dp), + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(roster, key = { it.pubkey }) { entry -> + ConcordMemberRow( + entry = entry, + communityId = communityId, + isSelf = entry.pubkey.equals(myPubKey, ignoreCase = true), + viewerIsOwner = iAmOwner, + viewerCanBan = iCanBan, + accountViewModel = accountViewModel, + nav = nav, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun ConcordMemberRow( + entry: RosterEntry, + communityId: String, + isSelf: Boolean, + viewerIsOwner: Boolean, + viewerCanBan: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = remember(entry.pubkey) { accountViewModel.checkGetOrCreateUser(entry.pubkey) } + val isOwnerTarget = entry.membership == ConcordMembership.OWNER + val isBanned = entry.membership == ConcordMembership.BANNED + val isAdmin = entry.membership == ConcordMembership.ADMIN + + // Owner can promote/demote anyone but the owner; ban is available to owner + BAN holders, + // never against the owner or yourself. A banned user only offers "unban". + val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf + val canBan = viewerCanBan && !isOwnerTarget && !isSelf + val hasMenu = canToggleAdmin || canBan + + androidx.compose.foundation.layout.Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserPicture(entry.pubkey, Size35dp, accountViewModel = accountViewModel, nav = nav) + Column(Modifier.weight(1f)) { + if (user != null) { + UsernameDisplay(user, accountViewModel = accountViewModel) + } else { + Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + MemberBadge(entry.membership) + if (hasMenu) { + var expanded by remember { mutableStateOf(false) } + IconButton(onClick = { expanded = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (canToggleAdmin) { + DropdownMenuItem( + text = { Text(stringRes(if (isAdmin) R.string.concord_members_remove_admin else R.string.concord_members_make_admin)) }, + onClick = { + accountViewModel.setConcordAdmin(communityId, entry.pubkey, makeAdmin = !isAdmin) + expanded = false + }, + ) + } + if (canBan) { + DropdownMenuItem( + text = { Text(stringRes(if (isBanned) R.string.concord_members_unban else R.string.concord_members_ban)) }, + onClick = { + accountViewModel.setConcordBan(communityId, entry.pubkey, ban = !isBanned) + expanded = false + }, + ) + } + } + } + } +} + +/** A small pill labelling the member's standing (owner / admin / banned; plain members render nothing). */ +@Composable +private fun MemberBadge(membership: ConcordMembership) { + val label = + when (membership) { + ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner) + ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin) + ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned) + else -> return + } + val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer + val content = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onPrimaryContainer + Surface(shape = RoundedCornerShape(6.dp), color = container) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = content, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +private class RosterEntry( + val pubkey: HexKey, + val membership: ConcordMembership, +) + +/** Owner first, then admins, then plain members, then banned last. */ +private fun ConcordMembership.sortRank(): Int = + when (this) { + ConcordMembership.OWNER -> 0 + ConcordMembership.ADMIN -> 1 + ConcordMembership.MEMBER -> 2 + ConcordMembership.NONE -> 3 + ConcordMembership.BANNED -> 4 + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt new file mode 100644 index 0000000000..fb712c7079 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -0,0 +1,153 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The shared metadata form for creating and editing a Concord community — a large + * circular icon preview at the top that reflects the icon URL live (tap it to jump + * to the URL field), then the name, description, and icon-URL fields. Mirrors the + * NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features + * feel consistent. Callers own the state and add the surrounding scaffold, relays + * section (create only), and the create/save action. + */ +@Composable +fun ConcordMetadataFields( + name: MutableState, + about: MutableState, + iconUrl: MutableState, + robotSeed: String, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val iconFocus = remember { FocusRequester() } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ConcordIconHero( + robotSeed = robotSeed, + iconUrl = iconUrl.value, + displayName = name.value, + accountViewModel = accountViewModel, + onClick = { iconFocus.requestFocus() }, + ) + + OutlinedTextField( + value = name.value, + onValueChange = { name.value = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text(stringRes(R.string.concord_create_name)) }, + ) + OutlinedTextField( + value = about.value, + onValueChange = { about.value = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 5, + label = { Text(stringRes(R.string.concord_create_about)) }, + ) + OutlinedTextField( + value = iconUrl.value, + onValueChange = { iconUrl.value = it }, + modifier = Modifier.fillMaxWidth().focusRequester(iconFocus), + singleLine = true, + label = { Text(stringRes(R.string.concord_create_icon)) }, + placeholder = { Text("https://…/icon.png") }, + ) + } +} + +/** The circular community-icon hero: shows the icon URL live over a stable robohash placeholder. */ +@Composable +private fun ConcordIconHero( + robotSeed: String, + iconUrl: String, + displayName: String, + accountViewModel: AccountViewModel, + onClick: () -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = + Modifier + .size(104.dp) + .clip(CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = iconUrl.ifBlank { null }, + contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) }, + modifier = Modifier.size(104.dp).clip(CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + } + Text( + text = stringRes(R.string.concord_create_icon_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(top = 8.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d3b7d8eda3..38613b8987 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -327,6 +327,21 @@ Ban Ban from this community? This member will be added to the community banlist. Their messages will be hidden and their future posts dropped by every member. You can unban them later. + Set a community icon + Relays that store this community\'s encrypted messages. Leave empty to use your own. + Edit community + Save + Members + No owner, admins, or banned members to show yet. + Make admin + Remove admin + Ban + Unban + Owner + Admin + Banned + Join community + Concord community invite encrypted legacy Looking for the original message… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index b576928541..c38f3858a4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -96,6 +97,26 @@ object ConcordModeration { return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) } + /** + * Replaces the community metadata (name / icon / description / relays). The + * metadata entity id is the community id itself (as in genesis), so this chains + * the next version onto the metadata head. Honored at fold only when [actor] + * holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation]. + */ + suspend fun editMetadata( + actor: NostrSigner, + controlPlane: GroupKey, + communityId: ByteArray, + metadata: MetadataEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.METADATA, communityId) + val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata) + return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) + } + /** Grants [member] exactly [roleIds] (replaces their prior grant). Empty list revokes all roles. */ suspend fun grant( actor: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index e5ed18453d..efefab9211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -55,6 +55,17 @@ class AuthorityResolver private constructor( /** The role ids a member currently holds (empty for the owner and for plain members). */ fun rolesOf(pubKey: String): Set = memberRoles[pubKey.lowercase()] ?: emptySet() + /** + * The set of pubkeys that hold at least one validly-granted role (lowercase + * hex). This is the *privileged* roster — admins/moderators and any other + * role-holders — and excludes the owner and silent key-holding members, since + * plain membership is key possession and leaves no Control-Plane trace. + */ + fun roleHolders(): Set = memberRoles.keys + + /** The healed banlist union (lowercase hex). */ + fun bannedMembers(): Set = banned + /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ fun rank(pubKey: String): Long? { val m = pubKey.lowercase() From c4657d482eb07422c8ffe14b2c725c1a93f3d936 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 02:03:10 +0000 Subject: [PATCH 049/115] feat(concord): invite card, deep links, and Messages group-by-community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the parity gaps with NIP-29 relay groups: - Rich invite card (ConcordInviteCard): an invite link in note content now renders as a tappable card that peeks the kind-33301 bundle (Account.peekConcordInvite) to show the community name, instead of a bare link. Wired into RichTextViewer. - Bare naddr (kind 33301) in ClickableRoute now shows an informative label rather than an empty addressable-note card (a naddr has no unlock token, so it can't be joined — only the full link can). - External invite URLs open the app: AndroidManifest intent-filter for amethyst.social/invite/*, and MainActivity.uriToRoute maps the full URL (fragment included) to Route.ConcordInvite so the redeem flow keeps the token. - Messages group-by-community view mode (ConcordViewMode INLINE/GROUPED), the analog of NIP-29's group-by-relay: GROUPED collapses each community's channels into one ConcordServerRoomNote row (rendered by ConcordServerRoomCompose, opens the channel list). Adds updateConcordViewMode + LocalPreferences persistence + feed invalidation + the ChatroomListKnownFeedFilter branch + a Messages-settings toggle. Also resolves a silent merge artifact: main's markDmRoomAsRead(signedEvents.msg) landed in the wraps-only broadcastPrivately overload (no signedEvents in scope); moved it to the Result overload that carries .msg. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- amethyst/src/main/AndroidManifest.xml | 10 ++ .../amethyst/LocalPreferences.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 7 +- .../amethyst/model/AccountSettings.kt | 7 + .../vitorpamplona/amethyst/ui/MainActivity.kt | 14 ++ .../amethyst/ui/components/ClickableRoute.kt | 15 ++ .../ui/components/ConcordInviteCard.kt | 133 ++++++++++++++++++ .../amethyst/ui/components/RichTextViewer.kt | 1 + .../loggedIn/AccountFeedContentStates.kt | 9 ++ .../chats/rooms/ChatroomHeaderCompose.kt | 53 +++++++ .../rooms/dal/ChatroomListKnownFeedFilter.kt | 93 ++++++++---- .../chats/rooms/dal/ConcordServerRoomNote.kt | 48 +++++++ .../settings/MessagesSettingsScreen.kt | 28 +++- amethyst/src/main/res/values/strings.xml | 7 + .../commons/model/concord/ConcordViewMode.kt | 2 + 15 files changed, 399 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index e8035be81f..5a7960c004 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -193,6 +193,16 @@ + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index aa759eed27..b437a6e838 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -26,6 +26,7 @@ import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm @@ -162,6 +163,7 @@ private object PrefKeys { const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy" const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode" + const val CONCORD_VIEW_MODE = "concord_view_mode" const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues" const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows" const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows" @@ -521,6 +523,7 @@ object LocalPreferences { putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name) putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name) + putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value) putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value) @@ -646,6 +649,7 @@ object LocalPreferences { ?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() } ?: RelayAuthPolicy.CUSTOM val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null)) + val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null)) val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true) val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true) val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true) @@ -859,6 +863,7 @@ object LocalPreferences { alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy), relayGroupViewMode = MutableStateFlow(relayGroupViewMode), + concordViewMode = MutableStateFlow(concordViewMode), relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays), relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows), relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 92b733913a..4cdad3b244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3275,7 +3275,10 @@ class Account( } } - suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) = broadcastPrivately(signedEvents.wraps) + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { + broadcastPrivately(signedEvents.wraps) + markDmRoomAsRead(signedEvents.msg) + } suspend fun broadcastPrivately(wraps: List) { val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) } @@ -3304,8 +3307,6 @@ class Account( // batcher re-delivers this note later; the processor's replay path and // the chatroom add are both idempotent. mineNote?.let { newNotesPreProcessor.consume(it) } - - markDmRoomAsRead(signedEvents.msg) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 5f9799dbdd..70f5e57625 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -310,6 +310,13 @@ class AccountSettings( } } + fun updateConcordViewMode(mode: ConcordViewMode) { + if (concordViewMode.value != mode) { + concordViewMode.tryEmit(mode) + saveAccountSettings() + } + } + // --- // Always-on Notification Service // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 8c6a05081f..d87ea9db23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.Account @@ -223,6 +224,7 @@ fun uriToRoute( } relayGroupInviteRoute(uri)?.let { return it } + concordInviteRoute(uri)?.let { return it } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { @@ -355,3 +357,15 @@ private fun relayGroupInviteRoute(uri: String): Route? { val link = GroupInviteLink.parse(uri.removePrefix(NOSTR_URI_PREFIX)) ?: return null return Route.RelayGroup(link.groupId, link.relayUrl.url, inviteCode = link.code) } + +/** + * A shared Concord invite URL (`…/invite/#`). Cheap substring gates + * keep the parse off the hot path; the whole URL (fragment included) is carried into + * the route so the redeem flow still has the unlock token. + */ +private fun concordInviteRoute(uri: String): Route? = + if (uri.contains("/invite/") && uri.contains('#') && ConcordActions.parseInviteLink(uri) != null) { + Route.ConcordInvite(uri) + } else { + null + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index e87ede251a..03ba880bb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.model.Note @@ -65,6 +66,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -217,6 +220,18 @@ private fun DisplayAddress( return } + // A Concord invite bundle (kind 33301) is addressed by a bare naddr, but redeeming it + // needs the 16-byte unlock token that only lives in the full invite link's #fragment — + // a naddr alone can't be joined. Show an informative label instead of the generic + // (and here always-empty) addressable-note card. + if (nip19.kind == ConcordKinds.INVITE_BUNDLE) { + Text( + text = stringRes(R.string.concord_invite_naddr_label) + (additionalChars ?: ""), + color = MaterialTheme.colorScheme.primary, + ) + return + } + var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.aTag())) } if (noteBase == null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt new file mode 100644 index 0000000000..c577c5d412 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ConcordInviteCard.kt @@ -0,0 +1,133 @@ +/* + * 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.components + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The rich card form of a Concord invite link in note content — the analog of + * NIP-29's `RelayGroupCard`. Tapping the card opens the redeem/join flow + * ([Route.ConcordInvite], which keeps the full URL so the fragment token + * survives). It fetches + unlocks the kind-33301 bundle in the background (via + * [com.vitorpamplona.amethyst.model.Account.peekConcordInvite]) to fill in the + * community name; until then it shows a stable placeholder so layout never jumps. + * + * Degrades to [ClickableConcordInviteLink] (a plain link) if the URL doesn't parse. + */ +@Composable +fun ConcordInviteCard( + linkText: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val parsed = remember(linkText) { ConcordActions.parseInviteLink(linkText) } + if (parsed == null) { + ClickableConcordInviteLink(linkText, nav) + return + } + + // Peek the bundle once per link to reveal the community name (null until it resolves). + val invite by produceState(initialValue = null, linkText) { + value = accountViewModel.account.peekConcordInvite(linkText) + } + + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + // Robohash seed: the community id once known (stable), else the link signer. + val robotSeed = invite?.communityId ?: parsed.linkSignerPubKey + val title = invite?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + ElevatedCard( + onClick = { nav.nav(Route.ConcordInvite(linkText)) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + RobohashFallbackAsyncImage( + robot = robotSeed, + model = null, + contentDescription = title, + modifier = + Modifier + .size(52.dp) + .clip(CircleShape) + .border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringRes(R.string.concord_invite_card_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + SymbolIcon( + symbol = MaterialSymbols.ChevronRight, + contentDescription = stringRes(R.string.concord_invite_card_join), + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 08c1566830..5a45896216 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -575,6 +575,7 @@ private fun RenderWordWithPreview( is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) is RelayGroupLinkSegment -> RelayGroupCard(word.segmentText, accountViewModel, nav) + is ConcordInviteLinkSegment -> ConcordInviteCard(word.segmentText, accountViewModel, nav) is BlossomUriSegment -> BlossomUriRenderer(word.segmentText, state, callbackUri, accountViewModel) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word.segmentText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index a3a3ec542a..2ee381764c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -190,6 +190,15 @@ class AccountFeedContentStates( } } + // Same for the Concord view mode (inline channels vs one row per community). + scope.launch(Dispatchers.IO) { + account.settings.concordViewMode + .drop(1) + .collect { + dmKnown.invalidateData() + } + } + // Pinning/unpinning a room only changes sort order, not membership, so no // chat event flows through LocalCache. Force a rebuild to re-sort. This // also fires when pins arrive via the synced AppSpecificData event. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 693accc760..1efa084f19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier @@ -119,6 +120,7 @@ fun ChatroomHeaderCompose( // would blank the row. val rendersWithoutEvent = baseNote is RelayGroupServerRoomNote || + baseNote is ConcordServerRoomNote || ( baseNote.event == null && baseNote.inGatherers?.any { it is MarmotGroupChatroom || it is RelayGroupChannel || it is ConcordChannel } == true @@ -165,6 +167,11 @@ private fun ChatroomEntry( return } + if (lastMessage is ConcordServerRoomNote) { + ConcordServerRoomCompose(lastMessage, accountViewModel, nav) + return + } + val marmotGroup = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { MarmotGroupRoomCompose(lastMessage, marmotGroup, accountViewModel, nav) @@ -511,6 +518,52 @@ private fun RelayGroupServerRoomCompose( ) } +@Composable +private fun ConcordServerRoomCompose( + row: ConcordServerRoomNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Community name/icon from the folded Control Plane (bumped via the session revision). + val revision by accountViewModel.account.concordSessions.revision + .collectAsStateWithLifecycle() + val metadata = + remember(row.communityId, revision) { + accountViewModel.account.concordSessions + .sessionFor(row.communityId) + ?.state + ?.value + ?.metadata + } + val name = metadata?.name?.takeIf { it.isNotBlank() } ?: stringRes(R.string.concord_home_title) + + val author = row.newestMessage?.author + val noteEvent = row.newestMessage?.event + val lastContent = + if (author != null && noteEvent != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${noteEvent.content.take(200)}" + } else { + stringRes(R.string.relay_group_no_messages_yet) + } + + ChannelName( + channelIdHex = row.communityId, + channelPicture = metadata?.icon, + channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.concord_server_label, modifier) }, + channelLastTime = row.newestMessage?.createdAt(), + channelLastContent = lastContent, + hasNewMessages = false, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, + onClick = { nav.nav(Route.ConcordServer(row.communityId)) }, + ) +} + /** A small tappable chip naming the relay a channel is hosted on. */ @Composable private fun RelayNameChip( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index cdad33a5d5..c6def6253c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.commons.util.replace @@ -130,22 +131,33 @@ class ChatroomListKnownFeedFilter( } } - // Concord Channels the user joined (kind 13302 list → folded Control Plane). Each folded - // channel is its own Messages row: its newest decrypted message (a real Note in LocalCache, - // attached to the ConcordChannel), or a placeholder for a just-joined channel with no - // messages yet. The note carries its ConcordChannel as a gatherer so the header renders it - // and a tap opens the encrypted chat — same shape as the Marmot/relay-group paths above. + // Concord Channels the user joined (kind 13302 list → folded Control Plane). In INLINE view + // mode each channel is its own Messages row (newest decrypted message — a real Note in + // LocalCache attached to the ConcordChannel — or a placeholder for a just-joined empty + // channel). In GROUPED mode all of a community's channels collapse into one community row + // positioned by that community's newest message. Concord groups by community exactly as + // NIP-29 groups by host relay above; both interleave with the rest of Messages by recency. val concordChannels = - account.concordSessions.sessions().flatMap { session -> - val state = session.state.value ?: return@flatMap emptyList() - state.channels.keys.map { channelIdHex -> - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) - channel.notes - .filter { _, it -> account.isAcceptable(it) && it.event != null } - .sortedByDefaultFeedOrder() - .firstOrNull() - ?: channel.placeholderNote() - } + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> + account.concordSessions.sessions().flatMap { session -> + val state = session.state.value ?: return@flatMap emptyList() + state.channels.keys.map { channelIdHex -> + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) + channel.newestConcordNote(account) ?: channel.placeholderNote() + } + } + + ConcordViewMode.GROUPED -> + // One row per joined community, carrying the newest message across ALL its channels. + account.concordSessions.sessions().mapNotNull { session -> + val state = session.state.value ?: return@mapNotNull null + val newest = + state.channels.keys + .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } + .maxByOrNull { it.createdAt() ?: 0L } + ConcordServerRoomNote(session.entry.id, newest) + } } return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups + concordChannels).toSet()) @@ -287,28 +299,48 @@ class ChatroomListKnownFeedFilter( } } - /** The row a Concord note belongs to: its ConcordChannel gatherer's stable key. */ - private fun Note.concordRowKey(): String? = inGatherers?.firstNotNullOfOrNull { (it as? ConcordChannel)?.channelId?.toKey() } + /** + * The row a Concord note belongs to, so [updateListWith] can find and replace it: a per-community + * [ConcordServerRoomNote] (GROUPED), else the note's ConcordChannel gatherer keyed by channel + * (INLINE) or by community (GROUPED), depending on the current view mode. + */ + private fun Note.concordRowKey(): String? = + when (this) { + is ConcordServerRoomNote -> communityId + else -> + inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { ch -> + when (account.settings.concordViewMode.value) { + ConcordViewMode.INLINE -> ch.channelId.toKey() + ConcordViewMode.GROUPED -> ch.channelId.communityId + } + } + } /** - * Latest Concord message per joined channel from the new items, keyed the same way as - * [concordRowKey] (one row per channel). A Concord message note carries its ConcordChannel - * as a gatherer (attached on decrypt), and only kind-9/1111 message-like rumors are attached - * as rows — reactions/deletes wire to their target note and never become a room's last message. + * Latest Concord rows from the new items, keyed the same way as [concordRowKey]: by channel in + * INLINE mode (one row per channel) and by community in GROUPED mode (one row per community, + * carried as a [ConcordServerRoomNote]). A Concord message note carries its ConcordChannel as a + * gatherer (attached on decrypt); only message-like rumors are attached as rows — reactions/ + * deletes wire to their target note and never become a room's last message. */ private fun filterRelevantConcordMessages( newItems: Set, account: Account, ): MutableMap { - val result = mutableMapOf() + // Newest new message per channel (INLINE) or per community (GROUPED). + val grouped = account.settings.concordViewMode.value == ConcordViewMode.GROUPED + val newestPerKey = mutableMapOf() newItems.forEach { newNote -> - val key = newNote.concordRowKey() ?: return@forEach + val channel = newNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return@forEach if (newNote.event == null || !account.isAcceptable(newNote)) return@forEach - val lastNote = result[key] - if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) { - result[key] = newNote - } + val key = if (grouped) channel.channelId.communityId else channel.channelId.toKey() + val last = newestPerKey[key] + if (last == null || (newNote.createdAt() ?: 0L) > (last.createdAt() ?: 0L)) newestPerKey[key] = newNote } + if (!grouped) return newestPerKey + // Wrap each community's newest into its collapsed server row. + val result = mutableMapOf() + newestPerKey.forEach { (communityId, note) -> result[communityId] = ConcordServerRoomNote(communityId, note) } return result } @@ -368,6 +400,13 @@ class ChatroomListKnownFeedFilter( .sortedByDefaultFeedOrder() .firstOrNull() + /** The newest decrypted message loaded in this Concord channel, or null if none yet. */ + private fun ConcordChannel.newestConcordNote(account: Account): Note? = + notes + .filter { _, it -> account.isAcceptable(it) && it.event != null } + .sortedByDefaultFeedOrder() + .firstOrNull() + /** * The row a relay-group note belongs to in the feed, so [updateListWith] can find and replace it: * a per-relay [RelayGroupServerRoomNote] (GROUPED), a joined group's chat note keyed by group id diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt new file mode 100644 index 0000000000..d57feda237 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ConcordServerRoomNote.kt @@ -0,0 +1,48 @@ +/* + * 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.rooms.dal + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A synthetic Messages-list row that collapses ALL of a user's channels in one Concord + * [communityId] into a single entry — the "grouped by community" view mode + * ([com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode.GROUPED]). It is the + * Concord analog of [RelayGroupServerRoomNote] (NIP-29 groups by host relay; Concord groups + * by community). + * + * It is not a real event: [event] stays null and [createdAt] mirrors [newestMessage] (the + * newest decrypted message across that community's channels) so the row interleaves with DMs + * and other chats by recency. Tapping it opens the community's channel list. Exactly one + * instance exists per community — keyed by a stable [idHex] so feed diffing and the LazyColumn + * treat it as the same row across refreshes. + */ +class ConcordServerRoomNote( + val communityId: HexKey, + val newestMessage: Note?, +) : Note(idFor(communityId)) { + override fun createdAt(): Long? = newestMessage?.createdAt() + + companion object { + fun idFor(communityId: HexKey): HexKey = "concordserver-$communityId" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt index edf13e06fe..0f0a0a2d67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/MessagesSettingsScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -70,6 +71,8 @@ fun MessagesSettingsScreen( ) { val mode by accountViewModel.account.settings.relayGroupViewMode .collectAsStateWithLifecycle() + val concordMode by accountViewModel.account.settings.concordViewMode + .collectAsStateWithLifecycle() Scaffold( topBar = { @@ -87,24 +90,43 @@ fun MessagesSettingsScreen( modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_inline), description = stringRes(R.string.relay_group_view_inline_desc), selected = mode == RelayGroupViewMode.INLINE, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.INLINE) }, ) - RelayGroupViewModeOption( + ViewModeOption( title = stringRes(R.string.relay_group_view_grouped), description = stringRes(R.string.relay_group_view_grouped_desc), selected = mode == RelayGroupViewMode.GROUPED, onSelect = { accountViewModel.account.settings.updateRelayGroupViewMode(RelayGroupViewMode.GROUPED) }, ) + + Text( + text = stringRes(R.string.concord_view_mode_title), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp), + ) + + ViewModeOption( + title = stringRes(R.string.concord_view_inline), + description = stringRes(R.string.concord_view_inline_desc), + selected = concordMode == ConcordViewMode.INLINE, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.INLINE) }, + ) + ViewModeOption( + title = stringRes(R.string.concord_view_grouped), + description = stringRes(R.string.concord_view_grouped_desc), + selected = concordMode == ConcordViewMode.GROUPED, + onSelect = { accountViewModel.account.settings.updateConcordViewMode(ConcordViewMode.GROUPED) }, + ) } } } @Composable -private fun RelayGroupViewModeOption( +private fun ViewModeOption( title: String, description: String, selected: Boolean, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index edd323241d..833e8e1df7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -342,6 +342,13 @@ Banned Join community Concord community invite + Concord invite (open the full invite link to join) + Concord + Concord community display + Inline + By community + Show each channel as its own conversation, mixed in with your chats. + Collapse each community\'s channels into a single row, placed at its newest message. encrypted legacy Looking for the original message… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt index 2974e8361d..a243b828b7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordViewMode.kt @@ -34,5 +34,7 @@ enum class ConcordViewMode { companion object { val DEFAULT = INLINE + + fun fromName(name: String?): ConcordViewMode = entries.firstOrNull { it.name == name } ?: DEFAULT } } From ec51d4020c4c533ad15cd3fd4402ba2747f6d972 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 03:30:26 +0000 Subject: [PATCH 050/115] feat(concord): stock-relay list import + single-screen community/channel browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things that make the Armada-interop scenario actually usable: - Bootstrap from the Concord stock relays: Account.importConcordCommunitiesFromStockRelays fetches this account's kind-13302 joined list from InviteRelayDictionary.STOCK (where the reference client publishes it — e.g. relay.ditto.pub — not the user's outbox) and folds the newest into LocalCache. Triggered on Concord-hub open (scoped so only Concord users reach those relays). Read-only/newest-wins, safe to repeat. This is why a community joined on Armada never showed up before: Amethyst only queried the user's own relays. - The hub is now a single-screen browser: a community rail across the top plus an expandable accordion where each community reveals its #/lock/mic channels inline — browse community-first, then channels, without leaving the screen. Mounts the live plane subscription so channels fold in while browsing; tapping a channel opens its chat, the community avatar opens the full server view. Still open for true two-client parity: verifying the 13302 encrypted-list JSON schema against a real Armada-written event (field-name decode) and union-merging on write. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 25 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../concord/ConcordHomeScreen.kt | 194 +++++++++++++++--- 3 files changed, 196 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4cdad3b244..2aeafc7c84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -136,10 +136,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -2149,6 +2151,29 @@ class Account( return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } } + /** + * Bootstrap the Concord hub from the network: fetch this account's kind-13302 + * joined-communities list from the Concord stock relays (where the reference + * client — Armada/Vector — publishes it, e.g. relay.ditto.pub) and fold the + * newest into [LocalCache], so communities we joined on another Concord client + * with this key surface here. Our outbox never carries that list, so without + * this a community joined on Armada would never appear. + * + * Read-only import: kind 13302 is replaceable, so folding an older copy is a + * no-op and this is safe to call on every hub open. Merging our own edits with + * a foreign writer's is a separate concern (newest-wins replaceable). + */ + suspend fun importConcordCommunitiesFromStockRelays() { + val relays = InviteRelayDictionary.STOCK.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return + val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }) + events + .filterIsInstance() + .maxByOrNull { it.createdAt } + ?.let { cache.justConsumeMyOwnEvent(it) } + } + // ── NIP-29 relay-group actions ─────────────────────────────────────────── // All group commands are published ONLY to the group's host relay, where // relay29 authorizes them. The relay is the source of truth; the kind-10009 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1fafa63d2d..59ea57b5ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -623,6 +623,12 @@ class AccountViewModel( if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) } + /** Pull the account's Concord community list from the stock relays (Concord hub bootstrap). */ + fun importConcordCommunities() = + viewModelScope.launch(Dispatchers.IO) { + account.importConcordCommunitiesFromStockRelays() + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 7db61c725f..058e0b9148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -20,15 +20,19 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +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.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,30 +44,45 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** - * The Concord Channels hub: lists the communities the account has joined (from the - * kind-13302 list) and offers a Create action. Concord has no public directory — - * communities are E2E-encrypted and invite-gated by design — so there is no browse - * feed here; you arrive at a community by creating one or redeeming an invite link. + * The Concord Channels hub — a single-screen browser of every community the account + * joined (kind-13302) and, expanded inline, that community's channels. A community + * rail across the top jumps to (and expands) any server; each row in the list below + * is a community you can expand to reveal its `#`/🔒/🎙 channels without leaving the + * screen. Tapping a channel opens its chat; the community header opens the full + * server view. + * + * Concord has no public directory — communities are E2E-encrypted and invite-gated — + * so there's no browse feed: you arrive by creating one, redeeming an invite, or (for + * a key already used on another Concord client) the on-open import from the stock + * relays below. The live plane subscription is mounted here so channels fold in while + * you browse. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,11 +90,22 @@ fun ConcordHomeScreen( accountViewModel: AccountViewModel, nav: INav, ) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + val account = accountViewModel.account val communities by account.concordChannelList.liveCommunities.collectAsStateWithLifecycle() - // Re-read folded metadata (icon / channel count) whenever a Control Plane folds. + // Re-read folded metadata (name / icon / channels) whenever a Control Plane folds. val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + // Concord clients (Armada/Vector) publish the kind-13302 joined list to the Concord + // stock relays, not the user's outbox, so a community joined there never surfaces at + // login. Pull it from those relays when the hub opens — scoped here so we only reach + // the stock relays for users who actually use Concord. + LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() } + + // Communities expanded in the accordion (multi-open, so several can show channels at once). + var expanded by remember { mutableStateOf(emptySet()) } + Scaffold( topBar = { TopAppBar( @@ -106,25 +136,62 @@ fun ConcordHomeScreen( modifier = Modifier.padding(horizontal = 32.dp), ) } - } else { - LazyColumn(Modifier.fillMaxSize().padding(padding)) { - items(communities, key = { it.id }) { entry -> + return@Scaffold + } + + Column(Modifier.fillMaxSize().padding(padding)) { + // Community rail: every joined community as an avatar; tap toggles its channels below. + CommunityRail( + communities = communities, + revision = revision, + expanded = expanded, + accountViewModel = accountViewModel, + onToggle = { id -> expanded = if (id in expanded) expanded - id else expanded + id }, + ) + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + + LazyColumn(Modifier.fillMaxSize()) { + communities.forEach { entry -> val state = - remember(entry.id, revision) { - account.concordSessions - .sessionFor(entry.id) - ?.state - ?.value + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + .takeIf { revision >= 0 } + val isOpen = entry.id in expanded + + item(key = entry.id) { + CommunityHeader( + communityId = entry.id, + name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, + iconUrl = state?.metadata?.icon, + channelCount = state?.channels?.size ?: 0, + expanded = isOpen, + accountViewModel = accountViewModel, + onToggle = { expanded = if (isOpen) expanded - entry.id else expanded + entry.id }, + onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, + ) + } + + if (isOpen && state != null) { + val channels = state.channels.entries.toList() + items(channels, key = { "${entry.id}/${it.key}" }) { ch -> + val def = ch.value.definition + ChannelSubRow( + name = def?.name ?: ch.key, + icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + }, + onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, + ) } - CommunityRow( - communityId = entry.id, - name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, - iconUrl = state?.metadata?.icon, - channelCount = state?.channels?.size ?: 0, - accountViewModel = accountViewModel, - onClick = { nav.nav(Route.ConcordServer(entry.id)) }, - ) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + item(key = "div-${entry.id}") { + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) + } } } } @@ -132,27 +199,70 @@ fun ConcordHomeScreen( } @Composable -private fun CommunityRow( +private fun CommunityRail( + communities: List, + revision: Int, + expanded: Set, + accountViewModel: AccountViewModel, + onToggle: (String) -> Unit, +) { + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + LazyRow( + Modifier.fillMaxWidth().padding(vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(communities, key = { it.id }) { entry -> + val iconUrl = + accountViewModel.account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + ?.metadata + ?.icon + .takeIf { revision >= 0 } + val isOpen = entry.id in expanded + val ring = if (isOpen) MaterialTheme.colorScheme.primary else Color.Transparent + RobohashFallbackAsyncImage( + robot = entry.id, + model = iconUrl, + contentDescription = entry.name, + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .border(2.dp, ring, CircleShape) + .clickable { onToggle(entry.id) }, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = autoPlayGif, + ) + } + } +} + +@Composable +private fun CommunityHeader( communityId: String, name: String, iconUrl: String?, channelCount: Int, + expanded: Boolean, accountViewModel: AccountViewModel, - onClick: () -> Unit, + onToggle: () -> Unit, + onOpen: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = - androidx.compose.foundation.layout.Arrangement - .spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { RobohashFallbackAsyncImage( robot = communityId, model = iconUrl, contentDescription = name, - modifier = Modifier.size(40.dp).clip(CircleShape), + modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = autoPlayGif, @@ -167,5 +277,31 @@ private fun CommunityRow( ) } } + SymbolIcon( + symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ChannelSubRow( + name: String, + icon: MaterialSymbol, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 40.dp, end = 16.dp) + .padding(vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon(symbol = icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) } } From f18ba03a482b254292b77bff3b1432b1b06f8ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 03:53:17 +0000 Subject: [PATCH 051/115] fix(concord): store the kind-13302 list replaceably so the hub populates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Concord hub was empty even after the 13302 arrived: ConcordCommunityListEvent extended plain Event, so LocalCache filed it by id and the dispatch when() had no branch for it — it fell through to consumeRegularEvent. But ConcordChannelListState observes the addressable note at Address(13302, pubkey, ""), which never received the event, so liveCommunities stayed empty. Make ConcordCommunityListEvent a BaseReplaceableEvent (kind 13302 is a NIP-01 replaceable — fixed empty d-tag, address = (kind, pubkey, "")) and add the consumeBaseReplaceable branch next to the kind-10009 list, so it lands in the addressable cache exactly like its NIP-29 sibling. The plane-wrap path (1059 -> concordSessions.ingest in DecryptAndIndexProcessor) was already correct. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 9 +++++++++ .../concord/cord02Community/ConcordCommunityListEvent.kt | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 6dc1630e79..206a76eabf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainZapResolver import com.vitorpamplona.amethyst.service.BundledInsert import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.note.dateFormatter +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent @@ -3772,6 +3773,14 @@ object LocalCache : ILocalCache, ICacheProvider { consumeBaseReplaceable(event, relay, wasVerified) } + // Concord private joined-communities list (kind 13302). Replaceable, self-encrypted; + // ConcordChannelListState observes it via the addressable cache (Address(13302, me, "")), + // so — exactly like the 10009 list above — it must be stored replaceably or the Concord + // hub stays empty even after the event arrives. + is ConcordCommunityListEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is GroupMetadataEvent -> { consume(event, relay, wasVerified) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index 96949c81d9..1bd59225bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.concord.cord02Community import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils @@ -48,7 +48,7 @@ class ConcordCommunityListEvent( tags: Array>, content: String, sig: HexKey, -) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { override fun isContentEncoded() = true /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ From c2cbd602bc76d70c622941a53a305558fab6e17a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:05:07 +0000 Subject: [PATCH 052/115] refactor(concord): chat plane reuses standard events + typed binding tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the nip88Polls-structure refactor (plan in quartz/plans/ 2026-07-11-concord-event-classes.md). A Concord chat rumor IS a standard Nostr event plus a channel/epoch binding, so stop re-deriving it by kind number: - New cord03Channels/tags/ChannelTag + EpochTag (typed, parse/assemble), plus TagArrayBuilderExt (channel/epoch/channelBinding) and TagArrayExt (concordChannel/concordEpoch/isConcordBoundTo) — the poll-package shape. - ChannelChat.message/reply now build via ChatEvent.build{ channelBinding(...) } and assemble the template into a rumor; reaction reuses ReactionEvent.KIND. The minimal e/p/k reaction tags and q/p reply tags are kept byte-identical for Armada interop (guarded by the Concord round-trip tests, which pass). - Drop the ConcordKinds.MESSAGE/COMMENT/REACTION/DELETE aliases (they shadowed ChatEvent/CommentEvent/ReactionEvent/DeletionEvent KINDs); callers use the real event KINDs. VoicePresence uses the new tag classes instead of bindingTags. Remaining Concord-specific kinds (control 3308, invites, guestbook, rekey, voice, seals/wrap) get their own per-kind packages in later slices per the plan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 3 +- .../model/concord/ConcordPlaneRegistryTest.kt | 4 +- .../plans/2026-07-11-concord-event-classes.md | 82 +++++++++++++++++++ .../concord/cord03Channels/ChannelChat.kt | 81 +++++++++--------- .../cord03Channels/TagArrayBuilderExt.kt | 46 +++++++++++ .../concord/cord03Channels/TagArrayExt.kt | 41 ++++++++++ .../concord/cord03Channels/tags/ChannelTag.kt | 47 +++++++++++ .../concord/cord03Channels/tags/EpochTag.kt | 47 +++++++++++ .../concord/cord07Voice/VoicePresence.kt | 7 +- .../quartz/concord/events/ConcordKinds.kt | 10 +-- 10 files changed, 316 insertions(+), 52 deletions(-) create mode 100644 quartz/plans/2026-07-11-concord-event-classes.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 0542dfc199..3d21adc1a1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** One decrypted, verified Concord channel message projected for display. */ data class ConcordChatMessage( @@ -173,7 +174,7 @@ object ConcordActions { ): List = wraps .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } - .filter { it.kind == ConcordKinds.MESSAGE && ChannelChat.isBoundTo(it, channelId, epoch) } + .filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) } .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } .sortedWith(compareBy({ it.createdAt }, { it.id })) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt index 09e5c3ae85..6f395de18e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -73,7 +73,7 @@ class ConcordPlaneRegistryTest { assertNotNull(routedMsg) assertEquals(ConcordPlaneKind.CHANNEL, routedMsg.plane.kind) assertEquals(community.generalChannelIdHex, routedMsg.plane.channelId?.channelId) - assertEquals(ConcordKinds.MESSAGE, routedMsg.opened.rumor.kind) + assertEquals(ChatEvent.KIND, routedMsg.opened.rumor.kind) assertEquals("gm", routedMsg.opened.rumor.content) // A wrap from an unrelated plane (different community) is not ours. diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md new file mode 100644 index 0000000000..fcf20e784f --- /dev/null +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -0,0 +1,82 @@ +# Concord event layer → per-kind Event classes (nip88Polls structure) + +## Why + +The Concord quartz layer currently centralizes wire kinds in a single +`concord/events/ConcordKinds.kt` constant object and hand-rolls rumors with raw +string tags (see `cord03Channels/ChannelChat.kt`: +`RumorAssembler.assembleRumor(kind = ConcordKinds.MESSAGE, tags = arrayOf(arrayOf("q", …)))`). +Two problems: + +1. **Duplicates standard Nostr kinds.** `ConcordKinds.MESSAGE=9`, `REACTION=7`, + `DELETE=5`, `COMMENT=1111`, `EDIT=3302` shadow `ChatEvent.KIND`, + `ReactionEvent.KIND`, `DeletionEvent.KIND`, `CommentEvent.KIND`. Concord chat + rumors *are* those standard events (they already parse back as `ChatEvent` + etc. on read) — the build side should reuse the classes, not re-derive by + number. +2. **No per-event structure.** Every other protocol in quartz gives each event + kind a package: `XxxEvent.kt` (the `Event` subclass + `build`), plus + `TagArrayBuilderExt.kt` / `TagArrayExt.kt` and a `tags/` folder of typed tag + classes (`nip88Polls/poll/…` is the reference). Concord instead has loose + builders (`ChannelChat`, `ControlEditionBuilder`) and stringly-typed tags. + +Target: match the `nip88Polls` shape. Reuse standard events where the protocol +uses standard kinds; give each genuinely-Concord kind its own package. + +## Reuse standard events (chat plane, CORD-03) + +A Concord chat rumor is a standard event + a `["channel", id]` + `["epoch", n]` +binding. Introduce a binding-tag package and reuse the standard builders: + +- `cord03Channels/tags/ChannelTag.kt`, `tags/EpochTag.kt` — typed tags. +- `cord03Channels/TagArrayBuilderExt.kt` — `channel(id)`, `epoch(n)` on the DSL. +- `cord03Channels/TagArrayExt.kt` — `channelId()`, `epoch()`, `isBoundTo(...)`. + +| rumor | reuse | binding | +|---|---|---| +| message (9) | `ChatEvent.build` | `channel` + `epoch` | +| reply (9 + q) | `ChatEvent.build` + `q`/`p` | `channel` + `epoch` | +| reaction (7) | `ReactionEvent.build` | `channel` + `epoch` | +| delete (5) | `DeletionEvent.build` | `channel` + `epoch` | + +`ChannelChat` keeps its public API (returns unsigned rumors via `RumorAssembler`) +but builds tags from the standard event's DSL + the binding ext. Delete +`ConcordKinds.MESSAGE/REACTION/DELETE/COMMENT/EDIT`. + +**Interop guard:** the exact on-wire tags must stay byte-identical to today's +output (Armada compat). Keep `["q", parentId]` + `["p", parentAuthor]` for +replies and `["e"/"p"/"k"]` for reactions — do not switch to `QEventTag`'s +3-element form. Verify with `ConcordPlaneRegistryTest` + an amy↔Armada round-trip. + +## New per-kind Event classes (genuinely Concord) + +Each gets `concord///XxxEvent.kt` + `TagArrayBuilderExt` + +`TagArrayExt` + `tags/`: + +| kind | event | CORD | +|---|---|---| +| 3308 | `ControlEditionEvent` (from `ControlEdition`/`ControlEditionBuilder`) | 02/04/06 | +| 3303 | `RekeyEvent` | 06 | +| 3306 / 3309 / 3312 | `GuestbookJoinLeaveEvent` / `KickEvent` / `SnapshotEvent` | 02 | +| 3313 | `DirectInviteEvent` | 05 | +| 23311 / 23313 | `TypingEvent` / `VoicePresenceEvent` | 03/07 | +| 3310 | `WebxdcEvent` | 03 | +| 33301 | `InviteBundleEvent` (from `ConcordInviteBundle`) | 05 | +| 13303 | `InviteListEvent` | 05 | +| 20013 / 20014 / 1059 / 21059 | envelope seals + inverted wrap | 01 | + +`ConcordCommunityListEvent` (13302) is already an `Event` class (now a +`BaseReplaceableEvent`) — keep, just move under a per-kind package if desired. + +After the move, `ConcordKinds` retains only the truly-Concord kinds (envelope, +control, rekey, guestbook, invites, voice), not the standard-Nostr aliases. + +## Sequencing (incremental, compile + interop-test each) + +1. Chat plane reuse (this doc's first section) — smallest blast radius (4 refs), + highest clarity. **← start here.** +2. Control plane 3308 → `ControlEditionEvent` package. +3. Invites 33301 / 3313 / 13303. +4. Guestbook + voice + rekey. +5. Envelope seals/wrap. +6. Shrink `ConcordKinds`, delete dead constants, update `EventFactory`. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 10f2feae47..dc337250d1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -20,36 +20,31 @@ */ package com.vitorpamplona.quartz.concord.cord03Channels -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** * Chat Plane message binding (CORD-03). * - * Every Chat Plane rumor — a message, reply, reaction, edit, or delete — commits - * to the channel and epoch it belongs to via `["channel", ]` and - * `["epoch", ]` tags inside the author-signed rumor. Recipients enforce this - * binding ([isBoundTo]) so an event lifted from one channel/epoch can't be - * replayed into another. + * A Concord chat rumor **is** a standard Nostr event — a kind-9 [ChatEvent] + * message/reply or a kind-7 [ReactionEvent] — that additionally commits to the + * channel and epoch it belongs to via `["channel", ]` + `["epoch", ]` tags + * (see [channel]/[epoch] and [ChannelTag]/[EpochTag]). This object reuses the + * standard event builders and only adds the binding, so the same event classes + * that render everywhere else in the app render Concord messages too. Recipients + * enforce the binding ([TagArray.isConcordBoundTo]) so an event lifted from one + * channel/epoch can't be replayed into another. */ object ChannelChat { - const val TAG_CHANNEL = "channel" - const val TAG_EPOCH = "epoch" - - /** Builds the channel/epoch binding tags shared by every Chat Plane rumor. */ - fun bindingTags( - channelId: HexKey, - epoch: Long, - ): Array> = arrayOf(arrayOf(TAG_CHANNEL, channelId), arrayOf(TAG_EPOCH, epoch.toString())) - /** - * Builds an unsigned kind-9 chat message rumor bound to [channelId]/[epoch]. + * Builds an unsigned kind-9 [ChatEvent] rumor bound to [channelId]/[epoch]. * Wrap it for the channel plane with - * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] (encrypted - * seal) to publish. + * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope] to publish. */ fun message( authorPubKey: HexKey, @@ -60,11 +55,11 @@ object ChannelChat { extraTags: Array> = emptyArray(), ): Event = RumorAssembler.assembleRumor( - pubKey = authorPubKey, - createdAt = createdAt, - kind = ConcordKinds.MESSAGE, - tags = bindingTags(channelId, epoch) + extraTags, - content = text, + authorPubKey, + ChatEvent.build(text, createdAt) { + channelBinding(channelId, epoch) + extraTags.forEach { addUnique(it) } + }, ) /** @@ -91,10 +86,12 @@ object ChannelChat { ) /** - * 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. + * Builds an unsigned kind-7 [ReactionEvent] rumor bound to [channelId]/[epoch] + * against the target message ([targetId]/[targetAuthor]/[targetKind]). [content] + * is the reaction (e.g. `"+"`, `"🤙"`). Kept to the minimal `e`/`p`/`k` tag form + * (no relay hints) so it stays wire-identical across clients. On the receiving + * side it decrypts to a normal kind-7 that wires to its target Note by the `e` + * tag through the shared cache. */ fun reaction( authorPubKey: HexKey, @@ -106,34 +103,34 @@ object ChannelChat { content: String, createdAt: Long, ): Event = - RumorAssembler.assembleRumor( + RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, - kind = ConcordKinds.REACTION, + kind = ReactionEvent.KIND, tags = - bindingTags(channelId, epoch) + - arrayOf( - arrayOf("e", targetId), - arrayOf("p", targetAuthor), - arrayOf("k", targetKind.toString()), - ), + arrayOf( + ChannelTag.assemble(channelId), + EpochTag.assemble(epoch), + 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) + fun channelOf(rumor: Event): HexKey? = rumor.tags.concordChannel() /** The epoch a Chat Plane [rumor] is bound to, or null if unbound/malformed. */ - fun epochOf(rumor: Event): Long? = rumor.tags.firstTagValue(TAG_EPOCH)?.toLongOrNull() + fun epochOf(rumor: Event): Long? = rumor.tags.concordEpoch() /** - * True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients - * must reject any Chat Plane event whose binding does not match the plane it - * arrived on. + * True when [rumor] is bound to exactly [channelId] and [epoch]. Recipients must + * reject any Chat Plane event whose binding does not match the plane it arrived on. */ fun isBoundTo( rumor: Event, channelId: HexKey, epoch: Long, - ): Boolean = channelOf(rumor) == channelId && epochOf(rumor) == epoch + ): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..d1f517ad72 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +/** + * The Concord Chat Plane binding, added to any standard event (kind 9 chat, 7 + * reaction, 5 delete, …) that is published on a channel plane. Because the binding + * layers onto reused standard events, these extensions are generic over the event + * type instead of pinned to a Concord-specific one. + */ +fun TagArrayBuilder.channel(channelId: HexKey) = addUnique(ChannelTag.assemble(channelId)) + +fun TagArrayBuilder.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch)) + +/** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */ +fun TagArrayBuilder.channelBinding( + channelId: HexKey, + epoch: Long, +) = apply { + channel(channelId) + epoch(epoch) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt new file mode 100644 index 0000000000..7158224348 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** The channel id this Chat Plane rumor is bound to, or null if unbound. */ +fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse) + +/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */ +fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse) + +/** + * True when these tags bind to exactly [channelId] and [epoch]. Recipients must + * reject any Chat Plane event whose binding does not match the plane it arrived on. + */ +fun TagArray.isConcordBoundTo( + channelId: HexKey, + epoch: Long, +): Boolean = concordChannel() == channelId && concordEpoch() == epoch diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt new file mode 100644 index 0000000000..29897734c9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/ChannelTag.kt @@ -0,0 +1,47 @@ +/* + * 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.quartz.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["channel", ]` tag that binds a Concord Chat Plane rumor to the channel + * it belongs to (CORD-03). Present on every message/reply/reaction/edit/delete so a + * recipient can reject an event lifted from another channel. + */ +class ChannelTag { + companion object { + const val TAG_NAME = "channel" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(channelId: HexKey) = arrayOf(TAG_NAME, channelId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt new file mode 100644 index 0000000000..804697e196 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt @@ -0,0 +1,47 @@ +/* + * 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.quartz.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["epoch", ]` tag that binds a Concord Chat Plane rumor to the community + * epoch it was authored under (CORD-03). Paired with [ChannelTag]; an event whose + * epoch does not match the plane it arrived on is rejected, so a message can't be + * replayed across a rekey. + */ +class EpochTag { + companion object { + const val TAG_NAME = "epoch" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt index 9a0eb88365..9b3376b4dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.quartz.concord.cord07Voice import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -69,7 +71,8 @@ object VoicePresence { subMs: Int? = null, ): Event { val tags = ArrayList>() - tags.addAll(ChannelChat.bindingTags(channelId, epoch)) + tags.add(ChannelTag.assemble(channelId)) + tags.add(EpochTag.assemble(epoch)) tags.add(arrayOf(TAG_IDENTITY, identity)) if (broker != null) tags.add(arrayOf(TAG_BROKER, broker)) if (subMs != null) tags.add(arrayOf("ms", subMs.toString())) @@ -82,7 +85,7 @@ object VoicePresence { channelId: HexKey, epoch: Long, createdAt: Long, - ): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, ChannelChat.bindingTags(channelId, epoch), CONTENT_LEFT) + ): Event = RumorAssembler.assembleRumor(authorPubKey, createdAt, KIND, arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), CONTENT_LEFT) fun parse(rumor: Event): VoicePresenceInfo? { if (rumor.kind != KIND) return null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt index 801b755c1f..d2eaf17a23 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt @@ -37,11 +37,11 @@ object ConcordKinds { const val SEAL_ENCRYPTED = 20013 const val SEAL_PLAINTEXT = 20014 - // Chat Plane rumors (CORD-03) - const val MESSAGE = 9 - const val COMMENT = 1111 - const val REACTION = 7 - const val DELETE = 5 + // Chat Plane rumors (CORD-03). + // Messages (kind 9), replies (9 + q), reactions (7), and deletes (5) are standard + // Nostr events — Concord reuses ChatEvent / ReactionEvent / DeletionEvent and only + // adds the channel/epoch binding (see cord03Channels/ChannelChat + tags/), so they + // are NOT aliased here. Only the Concord-specific chat kinds remain. const val EDIT = 3302 const val WEBXDC = 3310 const val TYPING = 23311 From dc2abc0e1c842b4475e884c13c2962009d6d3656 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:27:49 +0000 Subject: [PATCH 053/115] refactor(concord): control edition (3308) gets its own event package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md). The kind-3308 control edition now follows the nip88Polls per-kind shape: - New cord04Roles/control/ package: ControlEditionEvent (the Event subclass + build{} template + typed accessors), TagArrayBuilderExt (vsk/eid/ev/ep/vac), TagArrayExt (readers), and tags/ classes VskTag/EidTag/EvTag/EpTag/VacTag with parse/assemble/isTag. - ControlEditionBuilder.rumor now delegates to ControlEditionEvent.build + RumorAssembler; the raw stringly-typed tag construction and the ControlEdition.TAG_* constants are gone. Tags stay in the fixed vsk,eid,ev,ep,vac order so chain rumor ids are unchanged. - ControlEdition.fromRumor reads the typed tags, preserving exact validation (present-but-malformed ep/vac still rejected; absent = genesis/owner). - Register kind 3308 -> ControlEditionEvent in EventFactory so decrypted control rumors parse as the typed class. The edition hash is over content fields (not tags), and all Concord tests — ControlEditionTest, the fold/authority suite, and the join-flow round-trip — pass, so the wire format and chain are preserved. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/cord04Roles/ControlEdition.kt | 47 ++++------ .../cord04Roles/ControlEditionBuilder.kt | 29 ++---- .../control/ControlEditionEvent.kt | 89 +++++++++++++++++++ .../cord04Roles/control/TagArrayBuilderExt.kt | 40 +++++++++ .../cord04Roles/control/TagArrayExt.kt | 45 ++++++++++ .../cord04Roles/control/tags/EidTag.kt | 46 ++++++++++ .../concord/cord04Roles/control/tags/EpTag.kt | 46 ++++++++++ .../concord/cord04Roles/control/tags/EvTag.kt | 41 +++++++++ .../cord04Roles/control/tags/VacTag.kt | 58 ++++++++++++ .../cord04Roles/control/tags/VskTag.kt | 46 ++++++++++ .../quartz/utils/EventFactory.kt | 2 + 11 files changed, 439 insertions(+), 50 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt index 5e36890830..3183f41d2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord04Roles.control.eid +import com.vitorpamplona.quartz.concord.cord04Roles.control.ev +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk import com.vitorpamplona.quartz.concord.crypto.EditionHash -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.firstTagValue -import com.vitorpamplona.quartz.nip01Core.core.firstTagValueAsLong -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** @@ -70,43 +72,30 @@ class ControlEdition( val hashHex: String get() = hash.toHexKey() companion object { - const val TAG_VSK = "vsk" - const val TAG_EID = "eid" - const val TAG_EV = "ev" - const val TAG_EP = "ep" - const val TAG_VAC = "vac" - /** * Parses a decrypted, verified kind-3308 [rumor] (its [author] is the * rumor's pubkey) into a [ControlEdition], or returns null if it is not a * well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`, * malformed hex, …) so the caller drops it rather than folding garbage. + * Reads the typed tags of [ControlEditionEvent]. */ fun fromRumor( rumor: Event, author: String = rumor.pubKey, ): ControlEdition? { - if (rumor.kind != ConcordKinds.CONTROL) return null - val entityKind = rumor.tags.firstTagValue(TAG_VSK)?.let { ControlEntityKind.of(it) } ?: return null - val entityId = - rumor.tags - .firstTagValue(TAG_EID) - ?.hexToByteArrayOrNull() - ?.takeIf { it.size == 32 } ?: return null - val version = rumor.tags.firstTagValueAsLong(TAG_EV) ?: return null - if (version < 0) return null + if (rumor.kind != ControlEditionEvent.KIND) return null + val entityKind = rumor.tags.vsk() ?: return null + val entityId = rumor.tags.eid() ?: return null + val version = rumor.tags.ev() ?: return null - val prevValue = rumor.tags.firstTagValue(TAG_EP) - val prevHash = if (prevValue.isNullOrBlank()) null else prevValue.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + // A present-but-malformed `ep` is a corrupt edition (reject); an absent (or blank) + // `ep` is the genesis edition (no previous hash). + val epTag = rumor.tags.firstOrNull { it.size >= 2 && it[0] == EpTag.TAG_NAME && it[1].isNotBlank() } + val prevHash = if (epTag == null) null else EpTag.parse(epTag) ?: return null - val vacTag = rumor.tags.firstOrNull { it.size >= 4 && it[0] == TAG_VAC } - val vac = - vacTag?.let { - val gid = it[1].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null - val gver = it[2].toLongOrNull() ?: return null - val ghash = it[3].hexToByteArrayOrNull()?.takeIf { b -> b.size == 32 } ?: return null - AuthorityCitation(gid, gver, ghash) - } + // Likewise a present-but-malformed `vac` is rejected; absent means owner-authored. + val vacTag = rumor.tags.firstOrNull { it.size >= 4 && it[0] == VacTag.TAG_NAME } + val vac = if (vacTag == null) null else VacTag.parse(vacTag) ?: return null return ControlEdition( entityKind = entityKind, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt index 393e4c4ec6..1998255221 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionBuilder.kt @@ -20,17 +20,17 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler /** * Builds unsigned kind-3308 Control Plane edition rumors (the inverse of * [ControlEdition.fromRumor]). Seal these with a plaintext (20014) seal and wrap * them on the community's Control Plane so the author's signature survives - * re-encryption across epochs. + * re-encryption across epochs. Reuses [ControlEditionEvent.build] for the wire + * shape (its typed `vsk`/`eid`/`ev`/`ep`/`vac` tags); this only stamps the author. */ object ControlEditionBuilder { /** @@ -47,22 +47,9 @@ object ControlEditionBuilder { content: String, createdAt: Long, authorityCitation: AuthorityCitation? = null, - ): Event { - val tags = ArrayList>(5) - tags.add(arrayOf(ControlEdition.TAG_VSK, entityKind.wire)) - tags.add(arrayOf(ControlEdition.TAG_EID, entityId.toHexKey())) - tags.add(arrayOf(ControlEdition.TAG_EV, version.toString())) - if (prevHash != null) tags.add(arrayOf(ControlEdition.TAG_EP, prevHash.toHexKey())) - if (authorityCitation != null) { - tags.add( - arrayOf( - ControlEdition.TAG_VAC, - authorityCitation.grantId.toHexKey(), - authorityCitation.grantVersion.toString(), - authorityCitation.grantHash.toHexKey(), - ), - ) - } - return RumorAssembler.assembleRumor(authorPubKey, createdAt, ConcordKinds.CONTROL, tags.toTypedArray(), content) - } + ): Event = + RumorAssembler.assembleRumor( + authorPubKey, + ControlEditionEvent.build(entityKind, entityId, version, content, prevHash, authorityCitation, createdAt), + ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt new file mode 100644 index 0000000000..3cff153bba --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A kind-3308 Control Plane edition (CORD-02/04) — versioned, chainable community + * state (metadata, roles, channels, grants, banlist, invites, …). Authored as an + * unsigned rumor, plaintext-sealed and wrapped on the community's Control Plane so + * the author signature survives re-encryption across epochs. + * + * The wire shape is the entity content plus the `vsk`/`eid`/`ev`/`ep`/`vac` + * binding tags (see this package's `tags/`). The folded domain view — with the + * derived [com.vitorpamplona.quartz.concord.crypto.EditionHash] chain — is + * [com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition], which reads these + * accessors. + */ +class ControlEditionEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun entityKind() = tags.vsk() + + fun entityId() = tags.eid() + + fun version() = tags.ev() + + fun prevHash() = tags.ep() + + fun authorityCitation() = tags.vac() + + companion object { + const val KIND = ConcordKinds.CONTROL + + /** + * Builds the edition template for [entityKind]/[entityId] at [version]. + * Tags are emitted in the fixed `vsk, eid, ev, ep?, vac?` order the chain's + * rumor ids depend on. Assemble it into a rumor with the author pubkey via + * `RumorAssembler`. + */ + fun build( + entityKind: ControlEntityKind, + entityId: ByteArray, + version: Long, + content: String, + prevHash: ByteArray? = null, + authorityCitation: AuthorityCitation? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + vsk(entityKind) + eid(entityId) + ev(version) + prevHash?.let { ep(it) } + authorityCitation?.let { vac(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..3882ee7101 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayBuilderExt.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder + +fun TagArrayBuilder.vsk(kind: ControlEntityKind) = addUnique(VskTag.assemble(kind)) + +fun TagArrayBuilder.eid(entityId: ByteArray) = addUnique(EidTag.assemble(entityId)) + +fun TagArrayBuilder.ev(version: Long) = addUnique(EvTag.assemble(version)) + +fun TagArrayBuilder.ep(prevHash: ByteArray) = addUnique(EpTag.assemble(prevHash)) + +fun TagArrayBuilder.vac(citation: AuthorityCitation) = addUnique(VacTag.assemble(citation)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt new file mode 100644 index 0000000000..e6fd30cf29 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/TagArrayExt.kt @@ -0,0 +1,45 @@ +/* + * 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.quartz.concord.cord04Roles.control + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +/** The Control Plane entity kind (`vsk`) this edition updates, or null if absent/unknown. */ +fun TagArray.vsk(): ControlEntityKind? = firstNotNullOfOrNull(VskTag::parse) + +/** The 32-byte entity id (`eid`), or null if absent/malformed. */ +fun TagArray.eid(): ByteArray? = firstNotNullOfOrNull(EidTag::parse) + +/** The edition version (`ev`), or null if absent/malformed/negative. */ +fun TagArray.ev(): Long? = firstNotNullOfOrNull(EvTag::parse) + +/** The previous-edition hash (`ep`), or null if absent (genesis) — see also the presence check in `fromRumor`. */ +fun TagArray.ep(): ByteArray? = firstNotNullOfOrNull(EpTag::parse) + +/** The authority citation (`vac`), or null if absent (owner-authored). */ +fun TagArray.vac(): AuthorityCitation? = firstNotNullOfOrNull(VacTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt new file mode 100644 index 0000000000..f9cb22b35b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EidTag.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["eid", ]` entity-id tag: the 32-byte id of the Control Plane entity a + * kind-3308 edition updates (its version chain is keyed by this id). + */ +class EidTag { + companion object { + const val TAG_NAME = "eid" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ByteArray? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } + } + + fun assemble(entityId: ByteArray) = arrayOf(TAG_NAME, entityId.toHexKey()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt new file mode 100644 index 0000000000..0dc3c9464d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EpTag.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["ep", ]` edition-prev tag: the 32-byte [hash][com.vitorpamplona.quartz.concord.crypto.EditionHash] + * of the previous edition in this entity's chain. Absent on the genesis edition. + */ +class EpTag { + companion object { + const val TAG_NAME = "ep" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ByteArray? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } + } + + fun assemble(prevHash: ByteArray) = arrayOf(TAG_NAME, prevHash.toHexKey()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt new file mode 100644 index 0000000000..edaf67d3ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The `["ev", ]` edition-version tag: the monotonically increasing version of a kind-3308 edition. */ +class EvTag { + companion object { + const val TAG_NAME = "ev" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull()?.takeIf { it >= 0 } + } + + fun assemble(version: Long) = arrayOf(TAG_NAME, version.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt new file mode 100644 index 0000000000..486b1cd3b2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["vac", , , ]` versioned-authority-citation + * tag: the exact Grant edition a delegated actor claims authority under (CORD-04). + * Absent when the owner authors the edition. Carries three values, so it needs the + * full tag (not just a value) to round-trip. + */ +class VacTag { + companion object { + const val TAG_NAME = "vac" + + fun isTag(tag: Array) = tag.has(3) && tag[0] == TAG_NAME + + fun parse(tag: Array): AuthorityCitation? { + ensure(tag.has(3)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + val grantId = tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + val grantVersion = tag[2].toLongOrNull() ?: return null + val grantHash = tag[3].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + return AuthorityCitation(grantId, grantVersion, grantHash) + } + + fun assemble(citation: AuthorityCitation) = + arrayOf( + TAG_NAME, + citation.grantId.toHexKey(), + citation.grantVersion.toString(), + citation.grantHash.toHexKey(), + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt new file mode 100644 index 0000000000..8d5fc6c974 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VskTag.kt @@ -0,0 +1,46 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `["vsk", ]` versioned-sub-kind tag: which Control Plane entity a + * kind-3308 edition updates (metadata `0`, role `1`, channel `2`, grant `3`, + * banlist `4`, …). See [ControlEntityKind]. + */ +class VskTag { + companion object { + const val TAG_NAME = "vsk" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): ControlEntityKind? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return ControlEntityKind.of(tag[1]) + } + + fun assemble(kind: ControlEntityKind) = arrayOf(TAG_NAME, kind.wire) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2efaa46950..4a16657327 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -605,6 +606,7 @@ class EventFactory { RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) + ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) From cbeff0e8d87500434035bd47e8de63bc20f91ef8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:36:12 +0000 Subject: [PATCH 054/115] docs(concord): mark chat + control refactor slices done in the plan Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- quartz/plans/2026-07-11-concord-event-classes.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md index fcf20e784f..6797caa577 100644 --- a/quartz/plans/2026-07-11-concord-event-classes.md +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -73,10 +73,12 @@ control, rekey, guestbook, invites, voice), not the standard-Nostr aliases. ## Sequencing (incremental, compile + interop-test each) -1. Chat plane reuse (this doc's first section) — smallest blast radius (4 refs), - highest clarity. **← start here.** -2. Control plane 3308 → `ControlEditionEvent` package. -3. Invites 33301 / 3313 / 13303. -4. Guestbook + voice + rekey. -5. Envelope seals/wrap. -6. Shrink `ConcordKinds`, delete dead constants, update `EventFactory`. +1. ✅ Chat plane reuse — `ChatEvent`/`ReactionEvent` + `cord03Channels/tags` + + ext; dropped `MESSAGE/REACTION/DELETE/COMMENT` aliases. (commit c2cbd602) +2. ✅ Control plane 3308 → `cord04Roles/control/ControlEditionEvent` package + + `tags/` (vsk/eid/ev/ep/vac) + ext; `EventFactory` registers 3308. (commit dc2abc0e) +3. Invites 33301 / 3313 / 13303. **← next** +4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). +5. Envelope seals (20013/20014) / wrap (1059/21059). +6. Shrink `ConcordKinds` to only kinds without their own event class; audit + `EventFactory` coverage. From bc365c8ca2922ee3b745f38363fcbeb4d5d5097a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:00:42 +0000 Subject: [PATCH 055/115] refactor(concord): invite bundle (33301) gets its own addressable event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md). The kind-33301 public invite bundle is now a proper addressable Event class: - New cord05Invites/bundle/ConcordInviteBundleEvent (BaseAddressableEvent), with a build{} template that emits the ['d',''] + ['vsk','6'] tags via the standard DTag ext and the shared VskTag (ControlEntityKind.INVITE_LIVE). Registered kind 33301 -> ConcordInviteBundleEvent in EventFactory so fetched bundles parse as the class. - ConcordInviteBundle.build now signs ConcordInviteBundleEvent.build(...) with the per-link key; the raw TAG_D/TAG_VSK/VSK_LIVE constants are gone. Tag order (d, vsk) is preserved so the bundle event id / naddr are unchanged — the cord05Invites tests (bundle round-trip + join flow) pass. Remaining invite kinds: 3313 direct-invite (rumor has empty tags; p/k index is on the giftwrap) and 13303 invite-list (unimplemented stub) — tracked in the plan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../plans/2026-07-11-concord-event-classes.md | 8 +- .../cord05Invites/ConcordInviteBundle.kt | 9 +-- .../bundle/ConcordInviteBundleEvent.kt | 73 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 4 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md index 6797caa577..82e57ac9a4 100644 --- a/quartz/plans/2026-07-11-concord-event-classes.md +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -77,8 +77,12 @@ control, rekey, guestbook, invites, voice), not the standard-Nostr aliases. ext; dropped `MESSAGE/REACTION/DELETE/COMMENT` aliases. (commit c2cbd602) 2. ✅ Control plane 3308 → `cord04Roles/control/ControlEditionEvent` package + `tags/` (vsk/eid/ev/ep/vac) + ext; `EventFactory` registers 3308. (commit dc2abc0e) -3. Invites 33301 / 3313 / 13303. **← next** -4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). +3. Invites: ✅ 33301 bundle → `cord05Invites/bundle/ConcordInviteBundleEvent` + (addressable, reuses `DTag` + `VskTag`; registered in `EventFactory`). Remaining: + 3313 direct-invite → `ConcordDirectInviteEvent` (the rumor has empty tags; the + `p`/`k` index lives on the giftwrap). 13303 invite-list has no implementation yet + (a bare `ConcordKinds` constant) — build it when the private-invite feature lands. +4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). **← next** 5. Envelope seals (20013/20014) / wrap (1059/21059). 6. Shrink `ConcordKinds` to only kinds without their own event class; audit `EventFactory` coverage. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 0d301703a4..88fc9e9702 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -51,10 +51,7 @@ class MintedInviteLink( * bundle. Pinned to the Concord v2 reference client. */ object ConcordInviteBundle { - const val KIND = ConcordKinds.INVITE_BUNDLE - const val TAG_D = "d" - const val TAG_VSK = "vsk" - const val VSK_LIVE = "6" + const val KIND = ConcordInviteBundleEvent.KIND private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @@ -68,7 +65,7 @@ object ConcordInviteBundle { val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) val content = Nip44.v2.encrypt(json(invite), bundleKey).encodePayload() val signer = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)) - return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_D, ""), arrayOf(TAG_VSK, VSK_LIVE)), content) + return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) } /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt new file mode 100644 index 0000000000..014237912b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -0,0 +1,73 @@ +/* + * 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.quartz.concord.cord05Invites.bundle + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The public invite bundle (CORD-05): a kind-33301 **addressable** event whose + * content is a [com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite] + * NIP-44-encrypted under the bundle key derived from the link's 16-byte unlock + * token. Tagged `["d",""]` (so `link_signer` has exactly one live bundle) and + * `["vsk","6"]` ([ControlEntityKind.INVITE_LIVE]). + * + * A relay that indexes the naddr never holds the token, so it can never open the + * bundle. Minting/parsing/validation live in + * [com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle]; this is the + * wire event it builds and that [com.vitorpamplona.quartz.utils.EventFactory] parses. + */ +class ConcordInviteBundleEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The versioned sub-kind marker (`vsk`), expected to be [ControlEntityKind.INVITE_LIVE]. */ + fun versionedSubKind() = tags.vsk() + + companion object { + const val KIND = ConcordKinds.INVITE_BUNDLE + + /** Builds the addressable bundle template carrying the already-encrypted [encryptedInvite]. */ + fun build( + encryptedInvite: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encryptedInvite, createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4a16657327..4957e42c81 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -24,6 +24,7 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -607,6 +608,7 @@ class EventFactory { RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig) From ae6983a7a82b10422d83a32e5f17b1b06c97ce48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:56:26 +0000 Subject: [PATCH 056/115] fix(concord): pasting an invite into Search opens the redeem flow, not a broken event screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search ran the term through Nip19Parser, which extracted the invite URL's embedded kind-33301 naddr and routed to the generic addressable-event screen — which has no renderer for 33301, so it showed 'unable to render kind 33301'. Detect a Concord invite link before the nip19 parse and route to Route.ConcordInvite (carrying the whole URL so the fragment token survives). Also guard the bare-naddr path: a 33301 naddr goes to the invite flow instead of the unrenderable event screen. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../loggedIn/search/SearchBarViewModel.kt | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 24fa394155..0a1b687fc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.search.SearchScope import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.search.SearchSource @@ -43,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.userUriPrefixes import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull @@ -186,6 +188,15 @@ class SearchBarViewModel( searchTerm .mapLatest { term -> if (term.isBlank()) return@mapLatest null + + // A Concord invite link (…/invite/#) embeds a kind-33301 naddr that + // Nip19Parser would otherwise extract and route to the generic event screen (which + // can't render 33301). Detect the invite first and open the redeem flow — the whole + // URL is carried so the fragment token survives. + if (ConcordActions.parseInviteLink(term) != null) { + return@mapLatest Route.ConcordInvite(term) + } + val parsed = runCatching { Nip19Parser.uriToRoute(term)?.entity } .onFailure { if (it is CancellationException) throw it } @@ -211,9 +222,17 @@ class SearchBarViewModel( } is NAddress -> { - LocalCache.consume(parsed) - routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account) - ?: Route.EventRedirect(parsed.aTag()) + // A bare kind-33301 naddr is a Concord invite bundle — not renderable as a + // generic addressable event (and unredeemable without the link's fragment + // token). Send it to the invite flow, which shows a clean "needs the full + // link" state rather than an "unable to render" event screen. + if (parsed.kind == ConcordInviteBundleEvent.KIND) { + Route.ConcordInvite(term) + } else { + LocalCache.consume(parsed) + routeFor(LocalCache.getOrCreateAddressableNote(parsed.address()), account) + ?: Route.EventRedirect(parsed.aTag()) + } } else -> { From 127d959c8dc7ac13b6f64e27d3bd6980b63ef2ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:56:28 +0000 Subject: [PATCH 057/115] refactor(concord): community-list, control, invite-bundle events own their kind literals Removes the CONTROL/COMMUNITY_LIST/INVITE_BUNDLE constants from ConcordKinds now that each has a dedicated Event class. Callers reference ControlEditionEvent.KIND, ConcordCommunityListEvent.KIND, and ConcordInviteBundleEvent.KIND directly, matching the nip88Polls per-kind event convention. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/components/ClickableRoute.kt | 4 ++-- .../amethyst/commons/actions/ConcordActions.kt | 3 ++- .../concord/cord02Community/ConcordCommunityList.kt | 7 +++---- .../cord02Community/ConcordCommunityListEvent.kt | 5 ++--- .../concord/cord04Roles/control/ControlEditionEvent.kt | 3 +-- .../quartz/concord/cord05Invites/ConcordInviteLink.kt | 6 +++--- .../cord05Invites/bundle/ConcordInviteBundleEvent.kt | 3 +-- .../quartz/concord/events/ConcordKinds.kt | 8 +++----- .../cord02Community/ConcordCommunityListTest.kt | 4 ++-- .../quartz/concord/cord04Roles/ControlEditionTest.kt | 10 +++++----- .../concord/cord05Invites/ConcordInviteLinkTest.kt | 4 ++-- 11 files changed, 26 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 03ba880bb4..a0533e07cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -67,7 +67,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.njumpLink import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -224,7 +224,7 @@ private fun DisplayAddress( // needs the 16-byte unlock token that only lives in the full invite link's #fragment — // a naddr alone can't be joined. Show an informative label instead of the generic // (and here always-empty) addressable-note card. - if (nip19.kind == ConcordKinds.INVITE_BUNDLE) { + if (nip19.kind == ConcordInviteBundleEvent.KIND) { Text( text = stringRes(R.string.concord_invite_naddr_label) + (additionalChars ?: ""), color = MaterialTheme.colorScheme.primary, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 3d21adc1a1..d4cc0044aa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -86,7 +87,7 @@ object ConcordActions { fun planeFilterFor(planePubKeysHex: List): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = planePubKeysHex) /** The public invite bundle for a link signer. */ - fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.INVITE_BUNDLE), authors = listOf(linkSignerPubKeyHex)) + fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) /** Pending direct invites addressed to the given member (indexed by k=3313). */ fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordKinds.DIRECT_INVITE.toString()))) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 98c09f7898..a0fbd34643 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import kotlinx.serialization.Serializable @@ -64,7 +63,7 @@ class ConcordCommunityListEntry( /** * The member's private, self-encrypted list of joined Concord communities - * (kind [ConcordKinds.COMMUNITY_LIST] = 13302, CORD-05) — the NIP-51 analog that + * (kind [ConcordCommunityListEvent.KIND] = 13302, CORD-05) — the NIP-51 analog that * lets a client return to the groups the user signed up for. Replaceable and * NIP-44-encrypted to the member's own key, so relays store only ciphertext. * @@ -79,7 +78,7 @@ object ConcordCommunityList { createdAt: Long, ): Event { val content = signer.nip44Encrypt(encode(entries), signer.pubKey) - return signer.sign(createdAt, ConcordKinds.COMMUNITY_LIST, emptyArray(), content) + return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content) } /** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */ @@ -98,7 +97,7 @@ object ConcordCommunityList { event: Event, signer: NostrSigner, ): List { - if (event.kind != ConcordKinds.COMMUNITY_LIST) return emptyList() + if (event.kind != ConcordCommunityListEvent.KIND) return emptyList() return try { decode(signer.nip44Decrypt(event.content, signer.pubKey)) } catch (_: Exception) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index 1bd59225bd..934841648a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.concord.cord02Community import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -30,7 +29,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils /** * The member's private, self-encrypted list of joined Concord communities (kind - * [ConcordKinds.COMMUNITY_LIST] = 13302, CORD-05). A replaceable event whose + * 13302, CORD-05). A replaceable event whose * `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON * — including each community's secrets (`community_root`, salt, epoch, * private-channel keys), so a single event both syncs membership across devices @@ -60,7 +59,7 @@ class ConcordCommunityListEvent( } companion object { - const val KIND = ConcordKinds.COMMUNITY_LIST + const val KIND = 13302 const val ALT = "Private list of joined Concord communities" /** The replaceable coordinate for a member's list: `(13302, pubkey, "")`. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt index 3cff153bba..4713d0c672 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/ControlEditionEvent.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.concord.cord04Roles.control import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -60,7 +59,7 @@ class ControlEditionEvent( fun authorityCitation() = tags.vac() companion object { - const val KIND = ConcordKinds.CONTROL + const val KIND = 3308 /** * Builds the edition template for [entityKind]/[entityId] at [version]. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index 3f32739fab..dff968e91f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -164,7 +164,7 @@ object ConcordInviteLink { token: ByteArray, relays: List? = null, ): String { - val naddr = NAddress.create(ConcordKinds.INVITE_BUNDLE, linkSignerPubKey, "", null) + val naddr = NAddress.create(ConcordInviteBundleEvent.KIND, linkSignerPubKey, "", null) val trimmed = base.trimEnd('/') return "$trimmed/invite/$naddr#${encodeFragment(token, relays)}" } @@ -183,7 +183,7 @@ object ConcordInviteLink { if (marker < 0) return null val naddr = url.substring(marker + "/invite/".length, hash) val parsed = NAddress.parse(naddr) ?: return null - if (parsed.kind != ConcordKinds.INVITE_BUNDLE) return null + if (parsed.kind != ConcordInviteBundleEvent.KIND) return null return ParsedInviteLink(naddr, parsed.author, parsed.kind, fragment) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt index 014237912b..8e17c755fb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.concord.cord05Invites.bundle import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -57,7 +56,7 @@ class ConcordInviteBundleEvent( fun versionedSubKind() = tags.vsk() companion object { - const val KIND = ConcordKinds.INVITE_BUNDLE + const val KIND = 33301 /** Builds the addressable bundle template carrying the already-encrypted [encryptedInvite]. */ fun build( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt index d2eaf17a23..4a0fea3718 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt @@ -55,12 +55,10 @@ object ConcordKinds { // Person-addressed rumor (CORD-05) const val DIRECT_INVITE = 3313 - // Control / rekey rumors (CORD-02/04/06) - const val CONTROL = 3308 + // Rekey rumor (CORD-06). Control 3308 now lives on ControlEditionEvent.KIND. const val REKEY = 3303 - // Bare bookkeeping events - const val INVITE_BUNDLE = 33301 // addressable, CORD-05 - const val COMMUNITY_LIST = 13302 // private self-list, CORD-05 + // Bare bookkeeping events. Community-list 13302 (ConcordCommunityListEvent.KIND) and + // invite-bundle 33301 (ConcordInviteBundleEvent.KIND) now own their literals. const val INVITE_LIST = 13303 // private self-list, CORD-05 } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index e883bc4b50..9259e09583 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord02Community -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest @@ -53,7 +53,7 @@ class ConcordCommunityListTest { val entries = listOf(entry("11".repeat(32), "Gamers"), entry("22".repeat(32), "Nostrichs")) val event = ConcordCommunityList.build(signer, entries, createdAt = 1_700_000_000L) - assertEquals(ConcordKinds.COMMUNITY_LIST, event.kind) + assertEquals(ConcordCommunityListEvent.KIND, event.kind) assertFalse(event.content.contains("Gamers")) // encrypted on the wire val parsed = ConcordCommunityList.parse(event, signer) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt index 6d3e836b13..85e1551f44 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.crypto.EditionHash -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -69,7 +69,7 @@ class ControlEditionTest { arrayOf("ep", prev.toHexKey()), arrayOf("vac", grantId.toHexKey(), "2", grantHash.toHexKey()), ) - val rumor = RumorAssembler.assembleRumor(author, 1_700_000_000L, ConcordKinds.CONTROL, tags, content) + val rumor = RumorAssembler.assembleRumor(author, 1_700_000_000L, ControlEditionEvent.KIND, tags, content) val ed = ControlEdition.fromRumor(rumor) assertNotNull(ed) @@ -92,7 +92,7 @@ class ControlEditionTest { // missing eid assertNull( ControlEdition.fromRumor( - RumorAssembler.assembleRumor(author, 1L, ConcordKinds.CONTROL, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"), + RumorAssembler.assembleRumor(author, 1L, ControlEditionEvent.KIND, arrayOf(arrayOf("vsk", "0"), arrayOf("ev", "0")), "{}"), ), ) // unknown vsk (bit 7 retired) @@ -101,7 +101,7 @@ class ControlEditionTest { RumorAssembler.assembleRumor( author, 1L, - ConcordKinds.CONTROL, + ControlEditionEvent.KIND, arrayOf(arrayOf("vsk", "7"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), "{}", ), @@ -112,7 +112,7 @@ class ControlEditionTest { @Test fun genesisHasNullPrevWhenEpAbsent() { val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")) - val ed = ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, ConcordKinds.CONTROL, tags, """{"name":"general"}""")) + val ed = ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, ControlEditionEvent.KIND, tags, """{"name":"general"}""")) assertNotNull(ed) assertNull(ed.prevHash) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt index 5e7dc437b7..b293e3596e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -20,8 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlin.io.encoding.Base64 @@ -83,7 +83,7 @@ class ConcordInviteLinkTest { val parsed = ConcordInviteLink.parseUrl(url) assertNotNull(parsed) assertEquals(signer, parsed.linkSignerPubKey) - assertEquals(ConcordKinds.INVITE_BUNDLE, parsed.kind) + assertEquals(ConcordInviteBundleEvent.KIND, parsed.kind) assertContentEquals(token, parsed.fragment.token) } From 0015b390852f870af5de459d081673f818066b5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:56:30 +0000 Subject: [PATCH 058/115] fix(concord): read/write the kind-13302 list in Armada's wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amethyst serialized the joined-community list (kind 13302) as a bare JSON array of flat camelCase entries, so it could not parse the document Soapbox Armada actually publishes — a community created in Armada never appeared in Amethyst even after the 13302 reached a shared relay. Rewrites the codec to Armada's communityList.ts shape: { entries: [ { community_id, seed: JoinMaterial, current: JoinMaterial, added_at } ], tombstones: [ { community_id, removed_at } ] }, where JoinMaterial is the snake_case per-snapshot key bundle (community_id, owner, owner_salt, community_root, root_epoch, channels[], relays, name, held_roots?, refounder?). Hydration prefers current over seed; liveness is derived from tombstones (an entry is dropped only when removed strictly after it was added). New create/join entries now stamp added_at (ms) so the liveness tiebreak works. Adds interop tests that decode a real Armada document and exercise the tombstone-after-add drop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 2 + .../cord02Community/ConcordCommunityList.kt | 136 ++++++++++++++++-- .../ConcordCommunityListTest.kt | 64 +++++++++ 3 files changed, 194 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2aeafc7c84..d24b5878a1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1837,6 +1837,7 @@ class Account( rootEpoch = community.rootEpoch, relays = relayUrls, name = name, + addedAt = TimeUtils.now() * 1000, ), ) return community.communityIdHex @@ -1901,6 +1902,7 @@ class Account( rootEpoch = bundle.rootEpoch, relays = bundle.relays, name = bundle.name, + addedAt = TimeUtils.now() * 1000, ) joinConcordCommunity(entry) return bundle.communityId diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index a0fbd34643..c59e604dc5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer /** A past root key for a specific epoch, kept so historical channel keys stay derivable. */ @Serializable @@ -39,6 +39,7 @@ class PrivateChannelKey( val channelId: String, val key: String, val epoch: Long, + val name: String = "", ) /** @@ -46,7 +47,8 @@ class PrivateChannelKey( * needed to re-derive the community's planes on any device: identity ([id], * [owner], [ownerSalt]), the current access [root] at [rootEpoch] plus past * [heldRoots], any [privateChannels] keys, bootstrap [relays], and a cached - * display [name]. + * display [name]. [addedAt] is the wire join timestamp (ms) that tiebreaks + * liveness against tombstones. */ @Serializable class ConcordCommunityListEntry( @@ -59,6 +61,7 @@ class ConcordCommunityListEntry( val privateChannels: List = emptyList(), val relays: List = emptyList(), val name: String = "", + val addedAt: Long = 0, ) /** @@ -67,10 +70,97 @@ class ConcordCommunityListEntry( * lets a client return to the groups the user signed up for. Replaceable and * NIP-44-encrypted to the member's own key, so relays store only ciphertext. * - * (Channels are not listed here: once the [root] is held, folding the Control - * Plane yields the community's channels.) + * The plaintext document is wire-compatible with Soapbox Armada's `communityList.ts`: + * `{ "entries": [ { "community_id", "seed": JoinMaterial, "current": JoinMaterial, + * "added_at" } ], "tombstones": [ { "community_id", "removed_at" } ] }`, where + * [JoinMaterialWire] is the snake_case per-snapshot key bundle. Liveness is derived — + * an entry is dropped only when a later tombstone removes it — and each entry keeps a + * [CommunityListEntryWire.seed] (backfill anchor) plus [CommunityListEntryWire.current] + * (latest) snapshot; we hydrate from `current`, falling back to `seed`. + * + * (Channels are not listed here beyond their private keys: once the [root] is held, + * folding the Control Plane yields the community's channels.) */ object ConcordCommunityList { + // ---- wire DTOs (snake_case, Armada communityList.ts) ---------------------- + + @Serializable + private class WireChannel( + val id: String, + val key: String, + val epoch: Long, + val name: String = "", + ) + + @Serializable + private class WireHeldRoot( + val epoch: Long, + val key: String, + ) + + @Serializable + private class JoinMaterialWire( + @SerialName("community_id") val communityId: String, + val owner: String, + @SerialName("owner_salt") val ownerSalt: String, + @SerialName("community_root") val communityRoot: String, + @SerialName("root_epoch") val rootEpoch: Long, + val channels: List = emptyList(), + val relays: List = emptyList(), + val name: String = "", + @SerialName("held_roots") val heldRoots: List = emptyList(), + val refounder: String? = null, + ) + + @Serializable + private class CommunityListEntryWire( + @SerialName("community_id") val communityId: String, + val seed: JoinMaterialWire? = null, + val current: JoinMaterialWire? = null, + @SerialName("added_at") val addedAt: Long = 0, + ) + + @Serializable + private class CommunityTombstoneWire( + @SerialName("community_id") val communityId: String, + @SerialName("removed_at") val removedAt: Long = 0, + ) + + @Serializable + private class CommunityListDoc( + val entries: List = emptyList(), + val tombstones: List = emptyList(), + ) + + private fun ConcordCommunityListEntry.toJoinMaterial() = + JoinMaterialWire( + communityId = id, + owner = owner, + ownerSalt = ownerSalt, + communityRoot = root, + rootEpoch = rootEpoch, + channels = privateChannels.map { WireChannel(it.channelId, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + heldRoots = heldRoots.map { WireHeldRoot(it.epoch, it.key) }, + ) + + private fun JoinMaterialWire.toEntry(addedAt: Long) = + ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = ownerSalt, + root = communityRoot, + rootEpoch = rootEpoch, + heldRoots = heldRoots.map { HeldRoot(it.epoch, it.key) }, + privateChannels = channels.map { PrivateChannelKey(it.id, it.key, it.epoch, it.name) }, + relays = relays, + name = name, + addedAt = addedAt, + ) + + // ---- build / codec -------------------------------------------------------- + /** Builds the encrypted kind-13302 list event from [entries], signed by [signer]. */ suspend fun build( signer: NostrSigner, @@ -81,13 +171,43 @@ object ConcordCommunityList { return signer.sign(createdAt, ConcordCommunityListEvent.KIND, emptyArray(), content) } - /** Serializes [entries] to the plaintext JSON that gets NIP-44 self-encrypted. */ - fun encode(entries: List): String = ConcordJson.instance.encodeToString(ListSerializer(ConcordCommunityListEntry.serializer()), entries) + /** Serializes [entries] to the plaintext JSON document that gets NIP-44 self-encrypted. */ + fun encode(entries: List): String { + val doc = + CommunityListDoc( + entries = + entries.map { e -> + val jm = e.toJoinMaterial() + CommunityListEntryWire( + communityId = e.id, + seed = jm, + current = jm, + addedAt = e.addedAt, + ) + }, + tombstones = emptyList(), + ) + return ConcordJson.instance.encodeToString(CommunityListDoc.serializer(), doc) + } - /** Parses the decrypted plaintext JSON back into entries, or empty on failure. */ + /** + * Parses the decrypted plaintext JSON document back into live entries, or empty on + * failure. An entry is live unless a tombstone for the same community removed it + * strictly after it was added; hydration prefers `current`, falling back to `seed`. + */ fun decode(json: String): List = try { - ConcordJson.instance.decodeFromString(ListSerializer(ConcordCommunityListEntry.serializer()), json) + val doc = ConcordJson.instance.decodeFromString(CommunityListDoc.serializer(), json) + val latestRemoval = HashMap() + for (t in doc.tombstones) { + val prev = latestRemoval[t.communityId] + if (prev == null || t.removedAt > prev) latestRemoval[t.communityId] = t.removedAt + } + doc.entries.mapNotNull { e -> + val removedAt = latestRemoval[e.communityId] + if (removedAt != null && e.addedAt <= removedAt) return@mapNotNull null + (e.current ?: e.seed)?.toEntry(e.addedAt) + } } catch (_: Exception) { emptyList() } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt index 9259e09583..9a9dea784d 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListTest.kt @@ -69,6 +69,70 @@ class ConcordCommunityListTest { assertTrue(ConcordCommunityList.parse(event, other).isEmpty()) // wrong key ⇒ nothing } + @Test + fun decodesArmadaWireDocument() { + // A document as Soapbox Armada writes it (communityList.ts): {entries:[{community_id, + // seed, current, added_at}], tombstones:[]} with snake_case JoinMaterial. + val json = + """ + { + "entries": [ + { + "community_id": "${"11".repeat(32)}", + "seed": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"bb".repeat(32)}", + "root_epoch": 0, + "channels": [], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox" + }, + "current": { + "community_id": "${"11".repeat(32)}", + "owner": "${"0f".repeat(32)}", + "owner_salt": "${"aa".repeat(32)}", + "community_root": "${"cc".repeat(32)}", + "root_epoch": 2, + "channels": [ + { "id": "${"ee".repeat(32)}", "key": "${"dd".repeat(32)}", "epoch": 2, "name": "secret" } + ], + "relays": ["wss://relay.ditto.pub"], + "name": "Soapbox", + "held_roots": [ { "epoch": 1, "key": "${"bb".repeat(32)}" } ] + }, + "added_at": 1700000000000 + } + ], + "tombstones": [] + } + """.trimIndent() + + val entries = ConcordCommunityList.decode(json) + assertEquals(1, entries.size) + val e = entries[0] + assertEquals("11".repeat(32), e.id) + assertEquals("Soapbox", e.name) + assertEquals("cc".repeat(32), e.root) // hydrated from `current`, not `seed` + assertEquals(2L, e.rootEpoch) + assertEquals(1700000000000L, e.addedAt) + assertEquals(listOf("wss://relay.ditto.pub"), e.relays) + assertEquals(1, e.privateChannels.size) + assertEquals("ee".repeat(32), e.privateChannels[0].channelId) + assertEquals("secret", e.privateChannels[0].name) + assertEquals(1, e.heldRoots.size) + assertEquals(1L, e.heldRoots[0].epoch) + } + + @Test + fun tombstoneAfterAddDropsEntry() { + val jm = """{"community_id":"${"11".repeat(32)}","owner":"${"0f".repeat(32)}","owner_salt":"${"aa".repeat(32)}","community_root":"${"bb".repeat(32)}","root_epoch":0,"channels":[],"relays":[],"name":"Gone"}""" + val json = + """{"entries":[{"community_id":"${"11".repeat(32)}","seed":$jm,"current":$jm,"added_at":100}],"tombstones":[{"community_id":"${"11".repeat(32)}","removed_at":200}]}""" + assertTrue(ConcordCommunityList.decode(json).isEmpty()) // removed after add ⇒ not live + } + @Test fun mergeKeepsFreshestEpochPerCommunity() { val a = listOf(entry("11".repeat(32), "Old", epoch = 1)) From 3cac9077ec97e486c2da664b5f50bf5277005058 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:12:32 +0000 Subject: [PATCH 059/115] refactor(concord): delete ConcordKinds; kinds live on their owning classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the last catch-all ConcordKinds constant object. Each remaining kind now lives on the class that owns it: - wrap/seal kinds were already ConcordStreamEnvelope.KIND_WRAP/SEAL_* — callers (ConcordActions, ConcordSubscriptionPlanner) reference those directly. - guestbook join/leave (3306) and kick (3309) become Guestbook.KIND_JOIN_LEAVE / KIND_KICK. - direct-invite (3313), voice-presence (23313), and rekey (3303) inline their literals on ConcordDirectInvite/VoicePresence/ConcordRekey. The unused edit/webxdc/typing/snapshot/invite-list/ephemeral-wrap constants are dropped; they will own their literals when those events get dedicated classes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 8 +-- .../actions/ConcordSubscriptionPlanner.kt | 4 +- .../concord/cord02Community/Guestbook.kt | 13 ++-- .../cord05Invites/ConcordDirectInvite.kt | 3 +- .../concord/cord06Rekey/ConcordRekey.kt | 3 +- .../concord/cord07Voice/VoicePresence.kt | 3 +- .../quartz/concord/events/ConcordKinds.kt | 64 ------------------- .../concord/cord02Community/GuestbookTest.kt | 5 +- 8 files changed, 19 insertions(+), 84 deletions(-) delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index d4cc0044aa..a724496c75 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink @@ -35,7 +36,6 @@ import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundle import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -81,16 +81,16 @@ object ConcordActions { // ---- relay filters (what to REQ) ----------------------------------------- /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ - fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = listOf(planePubKeyHex)) + fun planeFilter(planePubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = listOf(planePubKeyHex)) /** Wraps across several plane addresses on one relay: kind-1059 authored by any of them. */ - fun planeFilterFor(planePubKeysHex: List): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), authors = planePubKeysHex) + fun planeFilterFor(planePubKeysHex: List): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = planePubKeysHex) /** The public invite bundle for a link signer. */ fun bundleFilter(linkSignerPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordInviteBundleEvent.KIND), authors = listOf(linkSignerPubKeyHex)) /** Pending direct invites addressed to the given member (indexed by k=3313). */ - fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordKinds.WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordKinds.DIRECT_INVITE.toString()))) + fun directInvitesFilter(memberPubKeyHex: HexKey): Filter = Filter(kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), tags = mapOf("p" to listOf(memberPubKeyHex), "k" to listOf(ConcordDirectInvite.KIND.toString()))) // ---- community lifecycle -------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index d8dc2db7ef..72edecb009 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -116,7 +116,7 @@ object ConcordSubscriptionPlanner { relay = relay, filter = Filter( - kinds = listOf(ConcordKinds.WRAP), + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), authors = authors.toList(), since = since?.get(relay)?.time, ), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt index 37ce1dfd35..d7566f394f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/Guestbook.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.concord.cord02Community -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue @@ -59,6 +58,10 @@ class GuestbookEntry( * [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. */ object Guestbook { + /** Guestbook rumor kinds (CORD-02): self-signed join/leave and authorized kick. */ + const val KIND_JOIN_LEAVE = 3306 + const val KIND_KICK = 3309 + const val TAG_MS = "ms" const val TAG_INVITE = "invite" const val TAG_P = "p" @@ -92,7 +95,7 @@ object Guestbook { if (inviteCreator != null) { tags.add(if (inviteLabel != null) arrayOf(TAG_INVITE, inviteCreator, inviteLabel) else arrayOf(TAG_INVITE, inviteCreator)) } - return RumorAssembler.assembleRumor(memberPubKey, createdAt, ConcordKinds.JOIN_LEAVE, tags.toTypedArray(), action.wire) + return RumorAssembler.assembleRumor(memberPubKey, createdAt, KIND_JOIN_LEAVE, tags.toTypedArray(), action.wire) } /** @@ -105,11 +108,11 @@ object Guestbook { actorPubKey: HexKey, target: HexKey, createdAt: Long, - ): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, ConcordKinds.KICK, arrayOf(arrayOf(TAG_P, target)), "") + ): Event = RumorAssembler.assembleRumor(actorPubKey, createdAt, KIND_KICK, arrayOf(arrayOf(TAG_P, target)), "") /** Parses a Guestbook join/leave rumor, or null if it is not one. */ fun parse(rumor: Event): GuestbookEntry? { - if (rumor.kind != ConcordKinds.JOIN_LEAVE) return null + if (rumor.kind != KIND_JOIN_LEAVE) return null val action = GuestbookAction.of(rumor.content) ?: return null val invite = rumor.tags.firstOrNull { it.size >= 2 && it[0] == TAG_INVITE } return GuestbookEntry( @@ -122,5 +125,5 @@ object Guestbook { } /** The kick target's pubkey from a kind-3309 rumor, or null. */ - fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == ConcordKinds.KICK) rumor.tags.firstTagValue(TAG_P) else null + fun kickTarget(rumor: Event): HexKey? = if (rumor.kind == KIND_KICK) rumor.tags.firstTagValue(TAG_P) else null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index 0e18eabfa8..910eaedb68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -41,7 +40,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent * It cannot be revoked — the recipient holds the keys the moment it lands. */ object ConcordDirectInvite { - const val KIND: Int = ConcordKinds.DIRECT_INVITE + const val KIND: Int = 3313 const val TAG_P = "p" const val TAG_K = "k" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index 60745ca6c9..0d5a0c1d3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip44Encryption.Nip44 @@ -106,7 +105,7 @@ object ConcordRekey { emptyList() } - const val KIND: Int = ConcordKinds.REKEY + const val KIND: Int = 3303 /** * Finds the recipient's rotated key across the [blobs] of one or more chunks, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt index 9b3376b4dc..9cb06f4925 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord07Voice/VoicePresence.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.concord.cord07Voice import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue @@ -51,7 +50,7 @@ class VoicePresenceInfo( * [HEARTBEAT_MS] and considered absent after [STALE_MS]. */ object VoicePresence { - const val KIND = ConcordKinds.VOICE_PRESENCE + const val KIND = 23313 const val CONTENT_JOINED = "joined" const val CONTENT_LEFT = "left" const val TAG_IDENTITY = "identity" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt deleted file mode 100644 index 4a0fea3718..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/events/ConcordKinds.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * 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.quartz.concord.events - -/** - * Every Nostr event kind used by the Concord protocol, pinned to the Concord v2 - * reference client (Soapbox Armada, `concord-v2/lib/kinds.ts`) for wire interop. - * - * Rumor kinds (9, 1111, 3302, 3303, 3306, 3308, 3309, 3310, 3312, 3313, 23311, - * 23313, 7, 5) never appear on the wire on their own — they are always sealed - * and wrapped by [com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope]. - * Only the wrap (1059/21059) and the bare bookkeeping kinds (33301, 13302, 13303) - * are published directly. - */ -object ConcordKinds { - // Envelope (CORD-01) - const val WRAP = 1059 - const val WRAP_EPHEMERAL = 21059 - const val SEAL_ENCRYPTED = 20013 - const val SEAL_PLAINTEXT = 20014 - - // Chat Plane rumors (CORD-03). - // Messages (kind 9), replies (9 + q), reactions (7), and deletes (5) are standard - // Nostr events — Concord reuses ChatEvent / ReactionEvent / DeletionEvent and only - // adds the channel/epoch binding (see cord03Channels/ChannelChat + tags/), so they - // are NOT aliased here. Only the Concord-specific chat kinds remain. - const val EDIT = 3302 - const val WEBXDC = 3310 - const val TYPING = 23311 - const val VOICE_PRESENCE = 23313 - - // Guestbook Plane rumors (CORD-02) - const val JOIN_LEAVE = 3306 - const val KICK = 3309 - const val SNAPSHOT = 3312 - - // Person-addressed rumor (CORD-05) - const val DIRECT_INVITE = 3313 - - // Rekey rumor (CORD-06). Control 3308 now lives on ControlEditionEvent.KIND. - const val REKEY = 3303 - - // Bare bookkeeping events. Community-list 13302 (ConcordCommunityListEvent.KIND) and - // invite-bundle 33301 (ConcordInviteBundleEvent.KIND) now own their literals. - const val INVITE_LIST = 13303 // private self-list, CORD-05 -} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt index 71968b1d04..30b30480ac 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/GuestbookTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.concord.cord02Community -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import kotlin.test.Test @@ -35,7 +34,7 @@ class GuestbookTest { @Test fun joinWithInviteAttributionRoundTrips() { val rumor = Guestbook.join(member, createdAt = 1_700_000_000L, subMs = 128, inviteCreator = creator, inviteLabel = "Reddit") - assertEquals(ConcordKinds.JOIN_LEAVE, rumor.kind) + assertEquals(Guestbook.KIND_JOIN_LEAVE, rumor.kind) assertEquals("join", rumor.content) val entry = Guestbook.parse(rumor) @@ -55,7 +54,7 @@ class GuestbookTest { @Test fun kickTargetsAMember() { val rumor = Guestbook.kick(actorPubKey = creator, target = target, createdAt = 1L) - assertEquals(ConcordKinds.KICK, rumor.kind) + assertEquals(Guestbook.KIND_KICK, rumor.kind) assertEquals(target, Guestbook.kickTarget(rumor)) } From 90998ee8383764506bc64807339d868ae84aa0db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:25:33 +0000 Subject: [PATCH 060/115] fix(concord): import the 13302 list from the user's own relays too, not just stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The community-list import only queried the 4 Concord stock relays, so a list the user copied onto their own outbox/read relays (or that a client published there) was never fetched — the normal account subscription never asks for kind 13302. Widens the query to stock ∪ mineRelays ∪ outboxRelays with a 30s window (stock relays like relay.ditto.pub can take 10–20s to first response), and logs how many 13302 events were fetched and how many entries decoded so an empty hub is diagnosable. Renames importConcordCommunitiesFromStockRelays → importConcordCommunities. Verified live: the account's newest kind-13302 is served without AUTH by the dreamith stock relay (and, slowly, relay.ditto.pub); the codec, LocalCache dispatch, and fold path all match. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 36 ++++++++++++------- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +-- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index ccd3f37133..f7a4cbd8e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2166,25 +2166,37 @@ class Account( /** * Bootstrap the Concord hub from the network: fetch this account's kind-13302 - * joined-communities list from the Concord stock relays (where the reference - * client — Armada/Vector — publishes it, e.g. relay.ditto.pub) and fold the - * newest into [LocalCache], so communities we joined on another Concord client - * with this key surface here. Our outbox never carries that list, so without - * this a community joined on Armada would never appear. + * joined-communities list and fold the newest into [LocalCache], so communities + * we joined on another Concord client with this key surface here. + * + * We query a wide relay set because different Concord clients publish this + * private list to different places: the reference clients (Armada/Vector) push + * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may + * also have copied it onto their **own** outbox/read relays. Our normal account + * subscription never asks for kind 13302, so without this explicit fetch a + * community joined on Armada would never appear — even if the list sits on the + * user's own outbox. * * Read-only import: kind 13302 is replaceable, so folding an older copy is a * no-op and this is safe to call on every hub open. Merging our own edits with * a foreign writer's is a separate concern (newest-wins replaceable). */ - suspend fun importConcordCommunitiesFromStockRelays() { - val relays = InviteRelayDictionary.STOCK.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + suspend fun importConcordCommunities() { + val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + val relays = (stock + mineRelays.flow.value + outboxRelays.flow.value).toSet() if (relays.isEmpty()) return val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(signer.pubKey)) - val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }) - events - .filterIsInstance() - .maxByOrNull { it.createdAt } - ?.let { cache.justConsumeMyOwnEvent(it) } + // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give + // the fetch a generous window to drain every relay before we pick the newest copy. + val events = client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = 30_000L) + val newest = events.filterIsInstance().maxByOrNull { it.createdAt } + val entryCount = newest?.let { runCatching { it.decrypt(signer).size }.getOrElse { -1 } } ?: 0 + Log.d( + "Concord", + "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + + "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}", + ) + newest?.let { cache.justConsumeMyOwnEvent(it) } } // ── NIP-29 relay-group actions ─────────────────────────────────────────── diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 59ea57b5ed..a1c0b8847c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -623,10 +623,10 @@ class AccountViewModel( if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) } - /** Pull the account's Concord community list from the stock relays (Concord hub bootstrap). */ + /** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */ fun importConcordCommunities() = viewModelScope.launch(Dispatchers.IO) { - account.importConcordCommunitiesFromStockRelays() + account.importConcordCommunities() } @Immutable From 60475c10c04d0f049b05cf95b55e268d6300eef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 01:21:58 +0000 Subject: [PATCH 061/115] =?UTF-8?q?feat(concord):=20NIP-42=20AUTH=20as=20t?= =?UTF-8?q?he=20derived=20plane=20stream=20key=20(CORD-01=20=C2=A74b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only to a connection authenticated AS the plane's derived stream key — a Concord wrap is authored by the stream key and p-tagged to a throwaway ephemeral key, so the member is neither author nor recipient. Amethyst authenticated only as the user, so every plane REQ was refused ('auth-required: all authors must be authenticated') and no channel or message ever loaded, even though the community list (kind 13302) folded correctly. Amethyst's relay-auth plumbing already supports multiple identities per connection: RelayAuthenticator sends one AuthCmd per event in the list its provider returns, deduped on (pubkey, challenge). This wires the derived stream keys into that provider: - ConcordCommunitySession.streamKeys() exposes control + folded-channel GroupKeys. - ConcordSessionManager.streamAuthSecretsFor(relay) returns the stream secret keys a NIP-42 challenge from that relay must be answered with (relay-scoped to each community's own relays — the same scope the plane subscription uses). - AuthCoordinator signs one kind-22242 per stream key locally (raw KeyPair via NostrSignerSync — never the account signer, never exposing user identity), and attaches them to every challenge independent of the user-auth policy. On auth success the existing syncFilters re-fires the previously-refused plane REQ. - RelayAuthStatus LruCaches widened 10 -> 200 since one connection now authenticates as the user plus many plane keys (control + channels). Mirrors Armada's streamAuth.ts (sign one AUTH per scoped stream key, accumulating on the connection). Verified live on-device by the debug pass: authing as the derived stream key returns the control-plane wraps that were previously refused. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../authCommand/model/AuthCoordinator.kt | 79 ++++++++++++++----- .../model/concord/ConcordCommunitySession.kt | 10 +++ .../model/concord/ConcordSessionManager.kt | 20 +++++ .../concord/ConcordSessionManagerTest.kt | 29 +++++++ .../relay/client/auth/RelayAuthStatus.kt | 9 ++- 5 files changed, 125 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 533d7c7c67..1db7d59e8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -24,9 +24,13 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -60,6 +64,13 @@ class AuthCoordinator( client, scope, signWithAllLoggedInUsers = { relayUrl, authTemplate -> + // Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the + // user: a relay serves a plane's kind-1059 wraps only to a connection authenticated + // as that stream key. These AUTHs expose no user identity (ephemeral derived keys) + // and are signed locally, so we always attach them — independent of the user-auth + // policy below — or Concord channels/messages never load. No-op for non-Concord relays. + val streamAuths = signConcordStreamAuths(relayUrl, authTemplate) + // Reconstruct *why* this relay wants auth from what we're doing with it, so each // account's ledger can apply follow-based trust and (later) explain the prompt. // Built lazily so the no-ledgers auto-allow path below doesn't pay for it. @@ -86,33 +97,63 @@ class AuthCoordinator( } val shouldAuth = outcome.shouldAuth - if (shouldAuth) { - // Remember why we granted this relay so the settings screen can explain it. - currentLedgers.firstOrNull()?.recordGrant(context) + val userAuths = + if (shouldAuth) { + // Remember why we granted this relay so the settings screen can explain it. + currentLedgers.firstOrNull()?.recordGrant(context) - // distinct() returns Set (the key type U of ListWithUniqueSetCache) - val results = - authWithAccounts.distinct().mapNotNull { - if (it.signer.isWriteable()) { - try { - it.signer.sign(authTemplate) - } catch (e: Exception) { - Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + // distinct() returns Set (the key type U of ListWithUniqueSetCache) + val results = + authWithAccounts.distinct().mapNotNull { + if (it.signer.isWriteable()) { + try { + it.signer.sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + null + } + } else { null } - } else { - null } - } - // Always auth, even with random keys - if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) - } else { - emptyList() - } + // Always auth, even with random keys (unless we're only here for stream auth). + if (results.isNotEmpty()) { + results + } else if (streamAuths.isEmpty()) { + listOf(tempAccount.sign(authTemplate)) + } else { + emptyList() + } + } else { + emptyList() + } + + streamAuths + userAuths }, ) + /** + * Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every + * watched account. Signed locally from the derived stream secret (a raw [KeyPair] via + * [NostrSignerSync]) — never the account signer, and never surfacing the user's identity. + */ + private suspend fun signConcordStreamAuths( + relayUrl: NormalizedRelayUrl, + authTemplate: EventTemplate, + ): List { + val secrets = authWithAccounts.distinct().flatMap { it.concordSessions.streamAuthSecretsFor(relayUrl) } + if (secrets.isEmpty()) return emptyList() + return secrets.mapNotNull { secret -> + try { + NostrSignerSync(KeyPair(privKey = secret)).sign(authTemplate) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e) + null + } + } + } + fun destroy() { receiver.destroy() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index f8e9e6b510..3c983f7085 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -84,6 +84,16 @@ class ConcordCommunitySession( /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + /** + * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus + * one per folded channel. These are the identities a NIP-42 relay must see the + * connection authenticate as (kind 22242) to serve the wraps — a Concord wrap is + * authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the + * member is neither author nor recipient and the relay refuses unless we AUTH as the + * stream key itself. + */ + fun streamKeys(): List = lock.withLock { listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second } } + /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index e68b6bc171..8a6fcb2065 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -103,6 +105,24 @@ class ConcordSessionManager( /** The `authors` set (control + known channel planes) for the kind-1059 subscription. */ fun subscribeAddresses(): Set = registry.subscribeAddresses() + /** + * The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]: + * every plane (control + folded channels) of every joined community whose relays + * include [relay]. Concord relays serve a plane's kind-1059 wraps only to a + * connection authenticated as that stream key, so the relay-auth layer signs a + * kind-22242 with each of these (locally, never the user's signer) — without them + * the connection is authed only as the user, the plane REQ is refused, and no + * channel or message ever loads. + */ + fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List { + val out = ArrayList() + for (session in registry.sessions()) { + val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relay in relays) session.streamKeys().forEach { out.add(it.secretKey) } + } + return out + } + /** Route an inbound stream wrap; true if it was a Concord plane wrap we applied. */ fun ingest(wrap: Event): Boolean { val applied = registry.ingest(wrap) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt index 044dccde7a..58b0c55253 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManagerTest.kt @@ -26,11 +26,13 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class ConcordSessionManagerTest { @@ -97,4 +99,31 @@ class ConcordSessionManagerTest { ?.name, ) } + + @Test + fun exposesStreamKeysScopedToTheCommunityRelaysForNip42Auth() = + runTest { + val alpha = ConcordCommunityFactory.create(owner, "Alpha", createdAt = 1L, relays = listOf("wss://r.example")) + val communities = MutableStateFlow(listOf(entryFor(alpha, "Alpha"))) + + val manager = ConcordSessionManager(communities, owner.pubKey, backgroundScope) + testScheduler.runCurrent() + + val hosted = RelayUrlNormalizer.normalize("wss://r.example") + val elsewhere = RelayUrlNormalizer.normalize("wss://other.example") + + // Before any fold, only the control-plane key must AUTH — and only on the community's relay. + val beforeFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } + assertTrue(beforeFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue(manager.streamAuthSecretsFor(elsewhere).isEmpty()) // relay-scoped + + // After the Control Plane folds, the #general channel key joins the AUTH set. + alpha.genesisWraps.forEach { manager.ingest(it) } + testScheduler.runCurrent() + val general = ConcordActions.publicChannel(alpha.communityRoot, alpha.generalChannelId, alpha.rootEpoch) + val afterFold = manager.streamAuthSecretsFor(hosted).map { it.toHexKey() } + assertTrue(afterFold.contains(alpha.controlPlane.secretKey.toHexKey())) + assertTrue(afterFold.contains(general.secretKey.toHexKey())) + assertFalse(manager.streamAuthSecretsFor(elsewhere).any { it.toHexKey() == general.secretKey.toHexKey() }) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index ee4df00fe2..47ac84c4ce 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -28,11 +28,14 @@ import kotlin.concurrent.Volatile class RelayAuthStatus { // Keeps track of auth responses to update the relay with all filters - // after the authentication happen - private val authResponseWatcher: LruCache = LruCache(10) + // after the authentication happen. + // Sized generously: one connection may authenticate as many identities at once — the + // user plus every Concord plane stream key hosted on that relay (control + channels) — + // and if older entries roll off, OK-tracking / hasFinishedAllAuths() accounting degrades. + private val authResponseWatcher: LruCache = LruCache(200) // Avoids sending multiple replies for each auth. - private val uniqueAuthChallengesSent: LruCache = LruCache(10) + private val uniqueAuthChallengesSent: LruCache = LruCache(200) // Latest epoch-second at which a tracked AUTH event received a successful OK. // Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive From bd42b1695be284660a61b793dfc7a3f1cf634427 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 02:32:17 +0000 Subject: [PATCH 062/115] fix(concord): hydrate unsigned channel rumors so messages render (not a placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord channel messages arrived as 6 rows stuck on the 'Event is loading or can't be found in your relay list' placeholder. A Concord inner rumor is unsigned (empty sig) and never exists on a relay, but consumeConcordRumor fed it to justConsume with wasVerified=false, so justVerify ran a signature check, failed, and the event never loaded onto its Note — the chat row then fell back to loading-from-relay and showed the placeholder forever. The rumor's authenticity is already established when the envelope is opened (ConcordStreamEnvelope.open verifies the seal signature, binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 gift-wrapped DM rumor. Consume it as pre-verified (wasVerified=true) so the event loads and the message text renders in the Messages tab alongside other groups. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../java/com/vitorpamplona/amethyst/model/LocalCache.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 206a76eabf..517a3ab972 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -753,7 +753,13 @@ object LocalCache : ILocalCache, ICacheProvider { if (rumor is ChatEvent || rumor is CommentEvent) { getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)).addNote(getOrCreateNote(rumor.id)) } - justConsume(rumor, null, false) + // wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature + // check would fail and the event would never load onto its Note — leaving the chat row + // stuck on the "loading / not found" placeholder. Its authenticity is already established + // by the envelope open path (ConcordStreamEnvelope.open verifies the seal signature, + // binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 + // gift-wrapped DM rumor, so we consume it as pre-verified. + justConsume(rumor, null, true) } fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { From 0bac5c7aca52a8b2dfd02d1b6d1c5df84f0d79bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:33:58 +0000 Subject: [PATCH 063/115] fix(concord): populate channel community metadata account-wide so the Messages chip shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Concord channel row in the Messages tab has a chip naming its parent community (tap → opens the community), mirroring the NIP-29 relay chip — but it only renders when ConcordChannel.communityName is set, and that was populated solely by refreshConcordChannelIndex() inside the Concord hub screen's subscription composable. On the Messages tab that screen isn't mounted, so the channel objects there never got their community name/icon and the chip was absent. Moves the channel-index refresh to an account-scoped collector on the ConcordSessionManager revision, so community metadata (name/icon, channel flags, membership) and per-community ban pruning apply across the whole app the moment a Control Plane folds — not only while the hub screen is open. The hub subscription now just re-derives its filters; the shared refresh lives in Account. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 37 +++++++++++++++++ .../datasource/ConcordChannelSubscription.kt | 41 +++---------------- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f7a4cbd8e5..c0a1d68dcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -137,6 +137,7 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -447,6 +448,35 @@ class Account( cache.consumeConcordRumor(communityId, channelIdHex, rumor) } + /** + * Copies each folded community's metadata (name/icon, channel flags, this account's + * membership) onto its [ConcordChannel] objects in the cache, and drops messages from + * authors banned since they loaded. Runs account-wide on every + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager] revision — + * NOT gated behind the Concord hub screen — so every surface (the Messages-tab + * community chip, the chat screen title) reflects the current fold, and bans apply, + * even when the hub was never opened. + */ + fun refreshConcordChannelIndex() { + val myPubKey = signer.pubKey + val relaysByCommunity = + concordChannelList.liveCommunities.value.associate { entry -> + entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + } + for (session in concordSessions.sessions()) { + val state = session.state.value ?: continue + val communityId = session.entry.id + val relays = relaysByCommunity[communityId] ?: emptySet() + for (channelIdHex in state.channels.keys) { + val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + channel.updateFrom(state, relays, myPubKey) + channel.notes + .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } + .forEach { channel.removeNote(it) } + } + } + } + val publicChatListDecryptionCache = PublicChatListDecryptionCache(signer) val publicChatList = PublicChatListState(signer, cache, publicChatListDecryptionCache, scope, settings) @@ -4713,6 +4743,13 @@ class Account( } } + // Keep Concord channel metadata (community name/icon, membership) live across the whole + // app — not just the hub screen — so the Messages tab renders each channel's community + // chip, and per-community bans apply, as soon as a Control Plane folds. + scope.launch { + concordSessions.revision.collect { refreshConcordChannelIndex() } + } + scope.launch { cache.antiSpam.flowSpam.collect { it.cache.spamMessages.snapshot().values.forEach { spammer -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 1491b5befc..034c31d7ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -26,16 +26,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer /** * Mount on any screen that lists the user's joined Concord Channels (the Messages * tab, the Concord home) to keep their planes live and their folded metadata in - * the [LocalCache] channel index. + * the LocalCache channel index. * * The query state is keyed on the account (stable), so the assembler wouldn't * re-run its filter derivation on its own when a community folds or the joined set @@ -57,39 +53,12 @@ fun ConcordChannelSubscription( val revision by account.concordSessions.revision.collectAsStateWithLifecycle() LaunchedEffect(revision) { - refreshConcordChannelIndex(account) + // The channel-index refresh (community name/icon, membership, ban pruning) runs + // account-wide from Account on this same revision, so the Messages tab has chips even + // when this screen was never opened. Here we only need to re-derive the subscription + // filters, since a newly-folded channel plane must now be subscribed. dataSource.invalidateFilters() } LifecycleAwareKeyDataSourceSubscription(state, dataSource) } - -/** - * Projects each folded community session into the shared LocalCache channel index - * so the Messages list and chat screens render an up-to-date [ConcordChannel] - * (name, voice/private flags, community name/relays, this account's membership). - */ -private fun refreshConcordChannelIndex(account: Account) { - val myPubKey = account.signer.pubKey - val relaysByCommunity = - account.concordChannelList.liveCommunities.value - .associate { entry -> - entry.id to entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - } - - for (session in account.concordSessions.sessions()) { - val state = session.state.value ?: continue - val communityId = session.entry.id - val relays = relaysByCommunity[communityId] ?: emptySet() - for (channelIdHex in state.channels.keys) { - val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) - channel.updateFrom(state, relays, myPubKey) - // A member banned since these notes loaded: drop their messages now (the - // ingest gate stops future ones). removeNote invalidates the feed, so the - // ban is reflected live rather than only on the next feed pass. - channel.notes - .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } - .forEach { channel.removeNote(it) } - } - } -} From 8065b045e45e226e0a71b7cf3e6b13d6e774c87a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:06:20 +0000 Subject: [PATCH 064/115] fix(concord): build thread replies as kind-1111 NIP-22 comments, not kind-9 quotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord replies were kind-9 chat messages carrying a `q` tag. In the Concord model (matching Soapbox Armada) a `q` on a kind-9 is an inline *quote*, which clients deliberately keep OUT of threads — a real thread reply is a kind-1111 NIP-22 comment. So our replies rendered (and were sent to Armada) as inline quotes, never grouping into a message's thread. ChannelChat.reply now builds a CommentEvent via CommentEvent.replyBuilder: the uppercase K/E/P tags pin the immutable thread root and the lowercase k/e/p tags point at the immediate parent (root inherited when the parent is itself a comment, so the root is stable at any depth), plus the same channel/epoch binding every Chat Plane rumor carries. This is byte-compatible with Armada's buildV2CommentTags, so replies thread correctly in both directions. The read path already accepts these (they carry the binding, and consumeConcordRumor handles CommentEvent), so incoming Armada thread replies now land bound to their channel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../commons/actions/ConcordActions.kt | 4 +-- .../concord/ConcordCommunitySessionTest.kt | 11 +++++-- .../concord/cord03Channels/ChannelChat.kt | 32 ++++++++++++------- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index a724496c75..6ee5ed6dd7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -135,7 +135,7 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } - /** Builds an encrypted-seal reply wrap (kind 9 quoting [parent]) on the [channel] plane. */ + /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ suspend fun buildChannelReply( authorSigner: NostrSigner, channel: GroupKey, @@ -145,7 +145,7 @@ object ConcordActions { text: String, createdAt: Long, ): Event { - val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt) + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 62b3dfba2c..f6090a8207 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -80,12 +81,16 @@ class ConcordCommunitySessionTest { 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. + // A reply decrypts as a kind-1111 NIP-22 thread comment: uppercase `E` at the thread + // root and lowercase `e` at the immediate parent (both the message here), still bound + // to the channel so it groups into the message's thread — the shape Armada threads. 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]) + assertEquals(1111, reply.kind) + assertEquals(message.id, reply.tags.first { it[0] == "E" }[1]) + assertEquals(message.id, reply.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(reply, community.generalChannelIdHex, community.rootEpoch)) // A stray wrap from a different community is ignored. val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index dc337250d1..acc65d34f4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -63,26 +65,32 @@ object ChannelChat { ) /** - * 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. + * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to + * [parent], bound to [channelId]/[epoch]. + * + * A thread reply is a NIP-22 comment — NOT a kind-9 message with a `q` tag + * (which NIP-C7 reserves for *inline quotes* that clients deliberately keep out + * of threads). [CommentEvent.replyBuilder] emits the uppercase `K`/`E`/`P` + * pointers at the immutable thread root and the lowercase `k`/`e`/`p` pointers + * at the immediate [parent] (inheriting the root when [parent] is itself a + * comment, so the root is stable at any depth). We add the same + * `["channel", …]` + `["epoch", …]` binding every Chat Plane rumor carries, so + * the reply is verifiable against the plane it arrives on. This is exactly the + * shape Soapbox Armada builds and groups into a message's thread. */ fun reply( authorPubKey: HexKey, channelId: HexKey, epoch: Long, text: String, - parentId: HexKey, - parentAuthor: HexKey, + parent: Event, createdAt: Long, ): Event = - message( - authorPubKey = authorPubKey, - channelId = channelId, - epoch = epoch, - text = text, - createdAt = createdAt, - extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)), + RumorAssembler.assembleRumor( + authorPubKey, + CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { + channelBinding(channelId, epoch) + }, ) /** From 025d63672fc667cabcdf902421a1c65f0ad6bf55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:39:57 +0000 Subject: [PATCH 065/115] =?UTF-8?q?feat(chat):=20dual-mode=20replies=20(in?= =?UTF-8?q?line=20+=20minichat)=20=E2=80=94=20foundation,=20Concord=20comp?= =?UTF-8?q?oser,=20shared=20chip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two-way reply model: an INLINE reply stays in the timeline (native chat message referencing its parent), a MINICHAT reply is a kind-1111 NIP-22 comment pulled into a thread opened from the parent — matching Armada. Foundation (quartz/commons): - ReplyMode enum (INLINE default, MINICHAT). - ChannelChat.inlineReply / ConcordActions.buildChannelInlineReply restore the kind-9 q-tag quote path alongside the existing kind-1111 ChannelChat.reply. - ChannelFeedFilter excludes kind-1111 CommentEvents from the chat timeline (they belong in the minichat), so inline replies stay and thread replies move aside. - observeNoteMinichatReplyCount: local count of a message's kind-1111 replies. Concord composer + send (amethyst): - ConcordNewMessageViewModel gains a replyMode state + toggle; the composer shows a "In chat" / "In thread" toggle beside the reply preview. - Account.sendConcordChannelMessage routes MINICHAT to kind-1111, INLINE to kind-9 quote, fresh post to kind-9 message. Shared row chip (all chat types): - ChatMessageCompose's action row shows an "N replies" chip when a message has kind-1111 thread replies; tapping opens the thread. Wired to the thread view for now; a chat-styled minichat screen and NIP-28/NIP-29 loading follow. Plan: amethyst/plans/2026-07-12-dual-reply-minichat.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../plans/2026-07-12-dual-reply-minichat.md | 113 ++++++++++++++++++ .../vitorpamplona/amethyst/model/Account.kt | 15 ++- .../reqCommand/event/EventObservers.kt | 30 +++++ .../loggedIn/chats/feed/ChatMessageCompose.kt | 54 +++++++++ .../concord/ConcordChannelScreen.kt | 55 +++++++++ .../send/ConcordNewMessageViewModel.kt | 21 +++- .../publicChannels/dal/ChannelFeedFilter.kt | 11 +- amethyst/src/main/res/values/strings.xml | 8 ++ .../commons/actions/ConcordActions.kt | 14 +++ .../amethyst/commons/viewmodels/ReplyMode.kt | 36 ++++++ .../concord/cord03Channels/ChannelChat.kt | 26 ++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 20 ++++ 12 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 amethyst/plans/2026-07-12-dual-reply-minichat.md create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt diff --git a/amethyst/plans/2026-07-12-dual-reply-minichat.md b/amethyst/plans/2026-07-12-dual-reply-minichat.md new file mode 100644 index 0000000000..7416c2becd --- /dev/null +++ b/amethyst/plans/2026-07-12-dual-reply-minichat.md @@ -0,0 +1,113 @@ +# Dual-mode replies: inline + "minichat" threads across all chats + +## Goal + +Give every Amethyst chat two ways to reply, chosen at send time: + +- **Inline reply** — a normal chat message that references its parent and stays in + the main timeline (today's behavior). On the wire this is the chat protocol's + native reply: NIP-C7 kind-9 with a `q` quote (Concord), kind-42 reply (NIP-28), + kind-9 `+h` reply (NIP-29), kind-14 reply (NIP-17 DM). +- **Minichat reply** — a **kind-1111 NIP-22 `CommentEvent`** rooted at the parent + message. It is pulled *out* of the main timeline and shown in a separate + **minichat** ("chat within a chat") opened from the parent. This matches Soapbox + Armada exactly (kind-9 `q` = inline quote, kind-1111 = thread). + +The rule is uniform and protocol-agnostic: **any kind-1111 whose root is a chat +message opens as that message's minichat.** So the same treatment automatically +covers Concord kind-9, NIP-28 kind-42, NIP-29 kind-9, and (later) NIP-17 kind-14 — +wherever a 1111 lands on a chat message. + +## Reuse survey (what already exists — do NOT rebuild) + +| Need | Reuse | +|---|---| +| kind-1111 reply builder (NIP-22 `K/E/P`+`k/e/p`) | `quartz/.../nip22Comments/CommentEvent.replyBuilder`; Concord's `ChannelChat.reply` already uses it | +| 1111 → parent wiring | `LocalCache.computeReplyTo` (CommentEvent branch) → `parentNote.replies`; minichat content = `note.replies.filter { it.event is CommentEvent }` | +| "N replies" chip | `observeNoteReplyCount(note, avm)` (EventObservers.kt) — already used by `RelayGroupThreadsScreen` | +| Shared per-row action strip | `ChatMessageCompose.NormalChatNote` `detailRow` `Row` — one place, every chat type | +| Thread rendering | `threadview/ThreadFeedView` + `ThreadAssembler.findThreadFor`; NIP-29 `RelayGroupThreadsScreen` as the chat-adjacent precedent | +| Per-message 1111 REQ (public chats) | `FilterRepliesAndReactionsToNotes` (kinds incl 1111, `#e`) via `EventFinder`; `RelayGroupThreadFeedFilterAssembler` (compose-scoped `#h`+1111 sub) | +| Composer reply state + "replying-to" preview | `*NewMessageViewModel.replyTo` + `chats/utils/DisplayReplyingToNote` | +| NIP-22 comment composer | `note/nip22Comments/CommentPostViewModel` (full-featured) | + +Concord already delivers kind-1111 replies through the existing channel-plane +subscription (they're wrapped like every other rumor), so **no new subscription is +needed for Concord** — only the timeline split, the chip, the minichat screen, and +the composer picker. + +## Design + +### 1. Wire model (settled — matches Armada) +- Inline reply → native chat reply event, native reply tags, stays in timeline. +- Minichat reply → kind-1111 `CommentEvent`: uppercase `K/E/P` at the immutable + thread root (the chat message), lowercase `k/e/p` at the immediate parent, plus + whatever binding the plane requires (Concord: `channel`/`epoch`). One level: + replying inside a minichat roots the new 1111 at the **same** root message + (parent = the message being answered, root = the minichat root), rendered flat — + so minichat messages don't spawn sub-threads. (The wire still permits nesting; + we render flat.) + +### 2. Timeline vs minichat split (rendering) +- **Main feed** excludes kind-1111 comments whose root is a chat message — they + live in the minichat, not as flat siblings. Implemented in the shared + `ChannelFeedFilter` / `ChatroomFeedFilter` by dropping `CommentEvent`s that root + onto a message already in the feed (keep everything else). +- Each root message row shows an **"N replies" chip** (from `observeNoteReplyCount` + restricted to CommentEvent replies) in the `detailRow` strip; tap → minichat route. + +### 3. Minichat screen +- A thread screen keyed by the **root message id** (+ the channel/room key needed to + re-derive the plane / re-subscribe). Renders the root message pinned at top, then + its kind-1111 replies as a flat mini-timeline (reuse `ChatroomMessageCompose`), with + its own composer that always sends kind-1111 rooted at this message. +- Back it with `ThreadFeedView`/`ThreadAssembler` where possible; for Concord, feed + it from `rootNote.replies` (already populated) + a lifecycle sub that keeps the + plane live. + +### 4. Composer mode picker +- Add `replyMode: ReplyMode {INLINE, MINICHAT}` next to `replyTo` in each + `*NewMessageViewModel` (Concord `ConcordNewMessageViewModel`, DM + `ChatNewMessageViewModel`, channels `ChannelNewMessageViewModel`). +- Render a small toggle beside `DisplayReplyingToNote` ("Reply in chat" ⇄ "Reply in + thread"). Default = INLINE (least surprise; user opts into pulling it aside). +- Send branch: `MINICHAT` routes to the kind-1111 builder + (`CommentEvent.replyBuilder` / Concord `buildChannelReply`), `INLINE` keeps the + native reply builder. + +### 5. Subscriptions +- **Concord**: none new (1111 arrives via the channel plane). Just ensure the + timeline filter and minichat read `rootNote.replies`. +- **NIP-28 / NIP-29 (phase 2)**: add a compose-scoped assembler (clone + `RelayGroupThreadFeedFilterAssembler`) that REQs `{kinds:[1111], "#e":[]}` (and `#E`) off the feed's current message-id set (from + `FeedContentState`). Reuse the same minichat screen/row. +- **NIP-17 DM (phase 3, later)**: kind-1111 replies must be gift-wrapped like the + kind-14s; deferred — needs an encrypted-comment path, more design. + +## Phasing + +1. **Phase 1 — Concord, full UX + all shared pieces.** ReplyMode enum + composer + toggle; timeline split (drop chat-rooted 1111s); "N replies" chip in the shared + `detailRow`; minichat route + screen; Concord send branch. Delivers the complete + dual-mode experience for Concord and builds every shared component. +2. **Phase 2 — public chats.** Per-message 1111 subscription for NIP-28 + NIP-29; + reuse the Phase-1 chip/screen/composer. NIP-29 already has a thread screen to + reconcile with. +3. **Phase 3 — DMs.** Gift-wrapped kind-1111 minichat for NIP-17. Deferred. + +## Decisions (settled) +- **Default mode** when tapping reply: **INLINE**. User opts into MINICHAT via the toggle. +- **Minichat depth**: **flat, one level**. Replying inside a minichat roots at the + same message; no sub-threads. +- **Scope now**: **Phase 1 + 2 together** — Concord AND public chats (NIP-28/NIP-29). + DMs (phase 3) still deferred. +- **Screen styling**: **chat-styled bubbles** (reuse `ChatroomMessageCompose`) so the + minichat reads as "a chat within a chat". + +## Verification +- quartz/commons unit tests for the reply-mode builders + the timeline-filter split + (a chat-rooted 1111 is excluded from the feed but present in `rootNote.replies`). +- On-device: in Concord, reply inline (stays in timeline) and reply-in-thread (opens + minichat); confirm Armada shows our minichat replies as a thread and its threads + open as our minichat; confirm the "N replies" chip count. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c0a1d68dcc..4e8c799aff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.logTime import com.vitorpamplona.amethyst.model.algoFeeds.FavoriteAlgoFeedsOrchestrator import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListDecryptionCache @@ -1962,6 +1963,7 @@ class Account( channelIdHex: String, text: String, replyTo: Note? = null, + replyMode: ReplyMode = ReplyMode.INLINE, ): Boolean { if (!isWriteable()) return false val session = concordSessions.sessionFor(communityId) ?: return false @@ -1970,10 +1972,15 @@ class Account( 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()) + when { + // A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9 + // message quoting the parent; a fresh post is a plain kind-9 message. + parent != null && replyMode == ReplyMode.MINICHAT -> + ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) + parent != null -> + ConcordActions.buildChannelInlineReply(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index c0ac72aa1e..29136e808b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity @@ -214,6 +215,35 @@ fun observeNoteReplyCount( return flow.collectAsStateWithLifecycle(note.replies.size) } +/** + * Count of a chat message's **minichat** replies — its kind-1111 [CommentEvent] + * children only (inline quote-replies are ordinary kind-9/42 messages and are not + * counted here). Drives the "N replies" chip that opens the minichat. + * + * Local-only: it reads the reply index the cache already holds, and does NOT open a + * per-note relay subscription (that would be one wasted REQ per visible row, and in + * Concord the kind-1111 replies arrive over the channel plane anyway). The replies for + * public chats are loaded once, feed-wide, by the chat screen's minichat subscription. + */ +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeNoteMinichatReplyCount( + note: Note, + accountViewModel: AccountViewModel, +): State { + val flow = + remember(note) { + note + .flow() + .replies.stateFlow + .sample(200) + .mapLatest { it.note.replies.count { reply -> reply.event is CommentEvent } } + .distinctUntilChanged() + } + + return flow.collectAsStateWithLifecycle(note.replies.count { it.event is CommentEvent }) +} + @Composable fun observeNoteReactions( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 30ca93806a..d76b6087d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -21,13 +21,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -42,8 +48,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment.Companion.CenterStart import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -96,6 +106,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as MaterialSymbolIcon @Composable fun ChatroomMessageCompose( @@ -280,6 +291,8 @@ fun NormalChatNote( ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + MinichatReplyChip(note, accountViewModel, nav) + val geo = remember(note) { note.event?.geoHashOrScope() } if (geo != null) { Spacer(StdHorzSpacer) @@ -384,6 +397,47 @@ private fun MessageBubbleLines( } } +/** + * A chip on a chat message's action row showing how many kind-1111 thread ("minichat") + * replies it has; tapping opens that thread. Shown only when there is at least one — an + * inline reply is an ordinary message and isn't counted. Shared across every chat type + * (Concord, NIP-28, NIP-29, DMs), since they all render through this row. + */ +@Composable +private fun MinichatReplyChip( + note: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val count by observeNoteMinichatReplyCount(note, accountViewModel) + if (count > 0) { + Spacer(StdHorzSpacer) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable { nav.nav(Route.Note(note.idHex)) }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + MaterialSymbolIcon( + symbol = MaterialSymbols.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(13.dp), + ) + Text( + text = pluralStringResource(R.plurals.chat_minichat_reply_count, count, count), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + @Composable fun RenderReplyRow( note: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 4a73b11278..1bc7facd2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -21,15 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar @@ -39,12 +45,15 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -146,6 +155,48 @@ fun ConcordChannelScreen( } } +/** + * A small segmented toggle shown above the composer while a reply is pending: send it + * as an inline message in the timeline, or pull the conversation aside into a minichat + * thread. Inline is the default; the user opts into the thread. + */ +@Composable +private fun ReplyModeToggle( + mode: ReplyMode, + onToggle: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + val minichat = mode == ReplyMode.MINICHAT + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onToggle), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) { + SymbolIcon( + symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(if (minichat) com.vitorpamplona.amethyst.R.string.chat_reply_in_thread else com.vitorpamplona.amethyst.R.string.chat_reply_in_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} + @Composable private fun ConcordMessageComposer( newMessageModel: ConcordNewMessageViewModel, @@ -163,6 +214,10 @@ private fun ConcordMessageComposer( newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() } + ReplyModeToggle( + mode = newMessageModel.replyMode.value, + onToggle = { newMessageModel.toggleReplyMode() }, + ) } Column(modifier = EditFieldModifier) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 0269a87713..bc22137a99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -55,6 +56,10 @@ open class ConcordNewMessageViewModel : ViewModel() { val message = TextFieldState() val replyTo = mutableStateOf(null) + // How the pending reply is delivered: INLINE stays in the timeline (kind-9 quote), + // MINICHAT pulls it into a thread (kind-1111). Only meaningful while replyTo is set. + val replyMode = mutableStateOf(ReplyMode.INLINE) + var userSuggestions: UserSuggestionState? = null open fun init(accountVM: AccountViewModel) { @@ -96,10 +101,22 @@ open class ConcordNewMessageViewModel : ViewModel() { fun reply(note: Note) { replyTo.value = note + replyMode.value = ReplyMode.INLINE + } + + /** Reply to [note] directly in a minichat thread (used from the minichat screen / long-press). */ + fun replyInMinichat(note: Note) { + replyTo.value = note + replyMode.value = ReplyMode.MINICHAT + } + + fun toggleReplyMode() { + replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE } fun clearReply() { replyTo.value = null + replyMode.value = ReplyMode.INLINE } fun editFromDraft(draftMessage: String) { @@ -134,10 +151,10 @@ open class ConcordNewMessageViewModel : ViewModel() { if (text.isEmpty()) return val parent = replyTo.value - account.sendConcordChannelMessage(community, channel, text, parent) + account.sendConcordChannelMessage(community, channel, text, parent, replyMode.value) message.clearText() - replyTo.value = null + clearReply() userSuggestions?.reset() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 521b12ae59..a4efd8291f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder +import com.vitorpamplona.quartz.nip22Comments.CommentEvent class ChannelFeedFilter( val channel: Channel, @@ -36,12 +37,16 @@ class ChannelFeedFilter( override fun changesFlow() = channel.changesFlow() - // returns the last Note of each user. - override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) }) + // A kind-1111 comment is a *minichat* reply — it lives in the thread opened from its + // root message, not as a flat sibling in the main timeline (an inline reply is a normal + // kind-9/42 message and stays). Everything else the channel gathered is a timeline message. + private fun isTimelineMessage(note: Note): Boolean = note.event !is CommentEvent && account.isAcceptable(note) + + override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> isTimelineMessage(it) }) override fun applyFilter(newItems: Set): Set = newItems - .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } + .filter { channel.notes.containsKey(it.idHex) && isTimelineMessage(it) } .toSet() override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c0559e4be3..3309ae1179 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -349,6 +349,14 @@ By community Show each channel as its own conversation, mixed in with your chats. Collapse each community\'s channels into a single row, placed at its newest message. + + In chat + In thread + + + %1$d reply + %1$d replies + encrypted legacy Looking for the original message… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 6ee5ed6dd7..ecee9a7b9b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -135,6 +135,20 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ + suspend fun buildChannelInlineReply( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + parent: Event, + text: String, + createdAt: Long, + ): Event { + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal thread-reply wrap (kind-1111 NIP-22 comment on [parent]) on the [channel] plane. */ suspend fun buildChannelReply( authorSigner: NostrSigner, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt new file mode 100644 index 0000000000..65106e421a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ReplyMode.kt @@ -0,0 +1,36 @@ +/* + * 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.commons.viewmodels + +/** + * How a chat reply is delivered, chosen by the user at send time. + * + * - [INLINE] — a normal chat message that references its parent and stays in the + * main timeline (the chat protocol's native reply: NIP-C7 kind-9 `q` quote, + * kind-42 reply, kind-9 `+h` reply, kind-14 reply). The default. + * - [MINICHAT] — a kind-1111 NIP-22 comment rooted at the parent message, pulled + * out of the timeline into a "chat within a chat" (minichat) opened from the + * parent. Wire-compatible with Soapbox Armada's thread replies. + */ +enum class ReplyMode { + INLINE, + MINICHAT, +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index acc65d34f4..419469d4b1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -64,6 +64,32 @@ object ChannelChat { }, ) + /** + * Builds an unsigned kind-9 **inline quote-reply** to [parentId]: a normal + * channel [message] that quotes the parent via a `q` tag (NIP-C7) and credits + * its author with a `p` tag. Unlike [reply] (a kind-1111 thread comment pulled + * into a minichat), an inline quote stays in the main chat timeline — the two + * reply modes the composer offers. Matches Armada, where a kind-9 `q` is an + * inline quote deliberately kept out of threads. + */ + fun inlineReply( + 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-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index ac202549c6..e2c554bf64 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -79,6 +79,26 @@ class ChannelChatEndToEndTest { assertEquals(0L, ChannelChat.epochOf(rumor)) } + @Test + fun inlineReplyIsAKind9QuoteWhileThreadReplyIsAKind1111Comment() { + val author = KeyPair().pubKey.toHexKey() + val parent = + ChannelChat.message(authorPubKey = author, channelId = channelIdHex, epoch = 0L, text = "root", createdAt = 1L) + + // Inline quote-reply: a normal kind-9 message, quoting the parent via `q`, still channel-bound. + val inline = ChannelChat.inlineReply(author, channelIdHex, 0L, "inline", parent.id, parent.pubKey, 2L) + assertEquals(9, inline.kind) + assertEquals(parent.id, inline.tags.first { it[0] == "q" }[1]) + assertTrue(ChannelChat.isBoundTo(inline, channelIdHex, 0L)) + + // Thread reply: a kind-1111 NIP-22 comment, uppercase `E` root + lowercase `e` parent, channel-bound. + val thread = ChannelChat.reply(author, channelIdHex, 0L, "thread", parent, 3L) + assertEquals(1111, thread.kind) + assertEquals(parent.id, thread.tags.first { it[0] == "E" }[1]) + assertEquals(parent.id, thread.tags.first { it[0] == "e" }[1]) + assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest { From e7ff2744a93f167078bc8dfe29e7e4f242fb009d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:50:52 +0000 Subject: [PATCH 066/115] feat(chat): chat-styled minichat screen + route, chip opens it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "chat within a chat" screen the N-replies chip now opens: the root message pinned at top, its kind-1111 thread replies below as chat bubbles (reusing ChatroomMessageCompose so they look identical to the main chat), and a composer that posts a kind-1111 rooted at that message — flat, so replying inside a minichat doesn't spawn sub-threads. - Route.ChatMinichat(rootId) + AppNavigation registration + MinichatScreen. - The shared "N replies" chip now navigates to the minichat instead of the generic thread view. - Account.sendMinichatReply resolves the chat context from the note's gatherer and drives the Concord channel path (kind-1111 on the plane). Observing the root's replies also loads kind-1111s from relays, so public-chat minichats already display; their send path (NIP-28/NIP-29) is the remaining follow-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 20 ++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 7 + .../loggedIn/chats/feed/ChatMessageCompose.kt | 2 +- .../loggedIn/chats/minichat/MinichatScreen.kt | 178 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 6 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4e8c799aff..56e5ecc5ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1986,6 +1986,26 @@ class Account( return true } + /** + * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that + * message. Resolves the chat context from the note's gatherer; today it drives the + * Concord channel path (NIP-28/NIP-29 public-chat minichats are a follow-up). Returns + * false if the message isn't in a chat we can post a thread reply to. + */ + suspend fun sendMinichatReply( + rootNote: Note, + text: String, + ): Boolean { + val concord = rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false + return sendConcordChannelMessage( + concord.channelId.communityId, + concord.channelId.channelId, + text, + rootNote, + ReplyMode.MINICHAT, + ) + } + /** * React to a Concord message with [reaction] (e.g. `"+"`, an emoji). Mirrors * [sendConcordChannelMessage]: builds a kind-7 rumor bound to the message's diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d58c8ad814..d49d68af89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -97,6 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroup import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupInfoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.minichat.MinichatScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.NewGroupDMScreen @@ -602,6 +603,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + MinichatScreen( + rootId = it.rootId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordChannelListScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b4ab7953bc..6c11434f12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -707,6 +707,13 @@ sealed class Route { @Serializable object Concords : Route() + // The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and + // rendered as a chat-within-a-chat. Keyed by the root message id; the screen resolves the chat + // context (Concord channel, public chat, relay group) from the note's gatherer. + @Serializable data class ChatMinichat( + val rootId: HexKey, + ) : Route() + @Serializable data class ChannelMetadataEdit( val id: String? = null, ) : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index d76b6087d4..96b3dafb98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -415,7 +415,7 @@ private fun MinichatReplyChip( Surface( shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.clickable { nav.nav(Route.Note(note.idHex)) }, + modifier = Modifier.clickable { nav.nav(Route.ChatMinichat(note.idHex)) }, ) { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt new file mode 100644 index 0000000000..6c9d917be6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -0,0 +1,178 @@ +/* + * 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.minichat + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +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.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.clearText +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.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * The minichat ("chat within a chat") of a single chat message: the message pinned at + * top, then its kind-1111 thread replies as chat bubbles, with a composer that always + * posts a kind-1111 rooted at that message (flat — replying inside a minichat doesn't + * spawn sub-threads). Opened from the "N replies" chip on any chat message. + * + * It reuses [ChatroomMessageCompose] so the bubbles look exactly like the main chat. + * Observing the root's replies also loads them from relays for public chats; Concord's + * thread replies keep arriving over the account-wide plane ingestion. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MinichatScreen( + rootId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) } + + // Loads + reacts to the root's replies (kind-1111 among them). + val replyState by observeNoteReplies(rootNote, accountViewModel) + val replies = + remember(replyState) { + rootNote.replies + .filter { it.event is CommentEvent } + .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) + } + + val composer = remember { TextFieldState() } + val scope = rememberCoroutineScope() + val context = LocalContext.current + val canPost by remember { derivedStateOf { composer.text.isNotBlank() } } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringRes(R.string.chat_minichat_title), fontWeight = FontWeight.Bold, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxHeight().padding(padding)) { + LazyColumn(Modifier.fillMaxWidth().weight(1f, true)) { + item("root") { + ChatroomMessageCompose( + baseNote = rootNote, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + HorizontalDivider() + } + items(replies, key = { it.idHex }) { reply -> + ChatroomMessageCompose( + baseNote = reply, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } + } + + Column(modifier = EditFieldModifier) { + ThinPaddingTextField( + state = composer, + modifier = Modifier.fillMaxWidth(), + shape = EditFieldBorder, + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + trailingIcon = { + ThinSendButton( + isActive = canPost, + modifier = EditFieldTrailingIconModifier, + ) { + val text = composer.text.toString().trim() + if (text.isNotEmpty()) { + composer.clearText() + scope.launch(Dispatchers.IO) { + try { + accountViewModel.account.sendMinichatReply(rootNote, text) + } catch (e: Exception) { + launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to send message: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + } + } + } + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3309ae1179..553edca79d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -352,6 +352,8 @@ In chat In thread + + Thread %1$d reply From 4324a8b8e48a66cea2cbc7d4477d04b6dd6c2b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:57:39 +0000 Subject: [PATCH 067/115] =?UTF-8?q?feat(chat):=20extend=20dual-mode=20repl?= =?UTF-8?q?ies=20to=20public=20chats=20(NIP-28/NIP-29)=20=E2=80=94=20Phase?= =?UTF-8?q?=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the minichat feature beyond Concord to the public chats: - ChannelNewMessageViewModel gains a replyMode state + toggle; createTemplate now builds a kind-1111 CommentEvent for a MINICHAT reply (with the NIP-29 `h` tag on relay groups) instead of the native reply, so users can start threads in NIP-28 public chats and NIP-29 relay groups. - Account.sendMinichatReply resolves PublicChatChannel and RelayGroupChannel from the note's gatherer and publishes a public kind-1111 (h-tagged + host-relay for groups), so replying inside a public-chat minichat works too. - observeNoteMinichatReplyCount re-registers each visible message with the EventFinder subscription, which batches their ids into shared REQs for kind-1111 replies — so the "N replies" chip appears on public-chat messages as their threads load. (Concord's replies still arrive over the plane; that REQ no-ops.) - The reply-mode toggle is extracted to a shared ReplyModeToggle composable used by both the Concord and public-chat composers. The chat-styled minichat screen already generalizes: it reads the root's kind-1111 replies and works for any chat type. DMs (NIP-17) remain the deferred Phase 3. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 48 +++++++++-- .../reqCommand/event/EventObservers.kt | 11 ++- .../concord/ConcordChannelScreen.kt | 52 +----------- .../send/ChannelNewMessageViewModel.kt | 22 +++++ .../chats/publicChannels/send/EditFieldRow.kt | 5 ++ .../loggedIn/chats/utils/ReplyModeToggle.kt | 84 +++++++++++++++++++ 6 files changed, 159 insertions(+), 63 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 56e5ecc5ee..3a2f13cb53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -228,6 +228,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NRelay import com.vitorpamplona.quartz.nip19Bech32.entities.NSec +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip29RelayGroups.GroupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag @@ -1996,14 +1997,45 @@ class Account( rootNote: Note, text: String, ): Boolean { - val concord = rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false - return sendConcordChannelMessage( - concord.channelId.communityId, - concord.channelId.channelId, - text, - rootNote, - ReplyMode.MINICHAT, - ) + if (!isWriteable()) return false + val gatherers = rootNote.inGatherers + + gatherers?.firstNotNullOfOrNull { it as? ConcordChannel }?.let { concord -> + return sendConcordChannelMessage( + concord.channelId.communityId, + concord.channelId.channelId, + text, + rootNote, + ReplyMode.MINICHAT, + ) + } + + // Public chats: a plain public kind-1111 comment rooted at the message. NIP-29 groups + // additionally carry the `h` tag and go only to the host relay. + val rootEvent = rootNote.event ?: return false + + gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> + val relays = chat.relays().ifEmpty { outboxRelays.flow.value } + val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, chat.relays().firstOrNull()))) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, relays) + return true + } + + gatherers?.firstNotNullOfOrNull { it as? RelayGroupChannel }?.let { group -> + val hostRelay = group.groupId.relayUrl + val signed = + signer.sign( + CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, hostRelay)) { + hTag(group.groupId.id) + }, + ) + cache.justConsumeMyOwnEvent(signed) + client.publish(signed, setOf(hostRelay)) + return true + } + + return false } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt index 29136e808b..4a98653b72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventObservers.kt @@ -220,10 +220,11 @@ fun observeNoteReplyCount( * children only (inline quote-replies are ordinary kind-9/42 messages and are not * counted here). Drives the "N replies" chip that opens the minichat. * - * Local-only: it reads the reply index the cache already holds, and does NOT open a - * per-note relay subscription (that would be one wasted REQ per visible row, and in - * Concord the kind-1111 replies arrive over the channel plane anyway). The replies for - * public chats are loaded once, feed-wide, by the chat screen's minichat subscription. + * Mounting this registers the message with [EventFinderFilterAssemblerSubscription], which + * batches the visible messages' ids into shared REQs for their replies (kind-1111 among + * them) — so for public chats (NIP-28/NIP-29) the thread replies load, and the chip appears, + * just by rendering the rows. Concord's kind-1111 replies instead arrive over the channel + * plane, so that REQ finds nothing there and is a harmless no-op. */ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable @@ -231,6 +232,8 @@ fun observeNoteMinichatReplyCount( note: Note, accountViewModel: AccountViewModel, ): State { + EventFinderFilterAssemblerSubscription(note, accountViewModel) + val flow = remember(note) { note diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 1bc7facd2b..cc0744eea2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -21,21 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord import android.widget.Toast -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar @@ -45,15 +39,12 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -67,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -155,48 +147,6 @@ fun ConcordChannelScreen( } } -/** - * A small segmented toggle shown above the composer while a reply is pending: send it - * as an inline message in the timeline, or pull the conversation aside into a minichat - * thread. Inline is the default; the user opts into the thread. - */ -@Composable -private fun ReplyModeToggle( - mode: ReplyMode, - onToggle: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - ) { - val minichat = mode == ReplyMode.MINICHAT - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.clickable(onClick = onToggle), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), - ) { - SymbolIcon( - symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(14.dp), - ) - Text( - text = stringRes(if (minichat) com.vitorpamplona.amethyst.R.string.chat_reply_in_thread else com.vitorpamplona.amethyst.R.string.chat_reply_in_chat), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } - } - } -} - @Composable private fun ConcordMessageComposer( newMessageModel: ConcordNewMessageViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index fe79dde482..3cc84fe57f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -85,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag @@ -143,6 +145,10 @@ open class ChannelNewMessageViewModel : val replyTo = mutableStateOf(null) + // INLINE keeps the reply in the timeline (native reply); MINICHAT sends a kind-1111 + // thread comment that opens as a minichat. Only meaningful while replyTo is set. + val replyMode = mutableStateOf(ReplyMode.INLINE) + var uploadState by mutableStateOf(null) // Stripping failure dialog @@ -223,11 +229,17 @@ open class ChannelNewMessageViewModel : open fun reply(replyNote: Note) { replyTo.value = replyNote + replyMode.value = ReplyMode.INLINE draftTag.newVersion() } + fun toggleReplyMode() { + replyMode.value = if (replyMode.value == ReplyMode.INLINE) ReplyMode.MINICHAT else ReplyMode.INLINE + } + fun clearReply() { replyTo.value = null + replyMode.value = ReplyMode.INLINE draftTag.newVersion() } @@ -421,6 +433,16 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null + + // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the + // channel type; NIP-29 groups additionally carry the `h` tag so the relay scopes it. + val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event + if (minichatParent != null) { + return CommentEvent.replyBuilder(message.text.toString(), EventHintBundle(minichatParent, channel.relays().firstOrNull())) { + if (channel is RelayGroupChannel) hTag(channel.groupId.id) + } + } + val messageText = message.text.toString() val tagger = NewMessageTagger( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index e9f150b50a..9908a7a753 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSug import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder @@ -80,6 +81,10 @@ fun EditFieldRow( DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.clearReply() } + ReplyModeToggle( + mode = channelScreenModel.replyMode.value, + onToggle = { channelScreenModel.toggleReplyMode() }, + ) } channelScreenModel.uploadState?.let { uploading -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt new file mode 100644 index 0000000000..6d2a56165a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ReplyModeToggle.kt @@ -0,0 +1,84 @@ +/* + * 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.utils + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** + * A small tappable chip shown above a chat composer while a reply is pending: it flips the + * reply between staying inline in the timeline and being pulled aside into a thread + * ("minichat"). Inline is the default; the user opts into the thread. Shared by every chat + * composer (Concord, public chats, relay groups). + */ +@Composable +fun ReplyModeToggle( + mode: ReplyMode, + onToggle: () -> Unit, +) { + val minichat = mode == ReplyMode.MINICHAT + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onToggle), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + ) { + SymbolIcon( + symbol = if (minichat) MaterialSymbols.Forum else MaterialSymbols.Chat, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(if (minichat) R.string.chat_reply_in_thread else R.string.chat_reply_in_chat), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } +} From fe780a4220b5d604af6c895a46845e16a9efd41a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:15:04 +0000 Subject: [PATCH 068/115] fix(concord,chat): address audit findings (mentions, re-index perf, auth coupling, cleanups) Follows the code-review of the dual-reply + Concord work: 1. Public-chat/relay-group minichat replies no longer drop @-mentions, hashtags, URL references, quotes, custom emojis, attachments, content-warning or expiration: ChannelNewMessageViewModel.createTemplate's MINICHAT branch now runs after NewMessageTagger and builds the kind-1111 from tagger.message with the same enrichment the inline path uses (parent author is already tagged by replyBuilder). 2. refreshConcordChannelIndex no longer runs a full re-index + ban rescan on every ingested message: the revision collector is sample(500)-throttled, coalescing bursts into at most one pass per window. 3. Concord stream-key AUTH is no longer blocked behind the user-auth prompt: on a relay that hosts our planes, the ASK path is DISMISSed so the derived stream-key AUTHs return immediately instead of waiting on (or being dropped by) a dialog. 4. Account.sendMinichatReply evaluates chat.relays() once instead of twice. 5. AuthCoordinator caches the per-stream-key NostrSignerSync by secret, so the secp256k1 keypair isn't re-derived for every plane on every relay challenge. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 14 ++++++---- .../authCommand/model/AuthCoordinator.kt | 17 +++++++++-- .../send/ChannelNewMessageViewModel.kt | 28 +++++++++++++------ 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3a2f13cb53..23d62797ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -329,6 +329,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -2015,10 +2016,10 @@ class Account( val rootEvent = rootNote.event ?: return false gatherers?.firstNotNullOfOrNull { it as? PublicChatChannel }?.let { chat -> - val relays = chat.relays().ifEmpty { outboxRelays.flow.value } - val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, chat.relays().firstOrNull()))) + val relays = chat.relays() + val signed = signer.sign(CommentEvent.replyBuilder(text, EventHintBundle(rootEvent, relays.firstOrNull()))) cache.justConsumeMyOwnEvent(signed) - client.publish(signed, relays) + client.publish(signed, relays.ifEmpty { outboxRelays.flow.value }) return true } @@ -4804,9 +4805,12 @@ class Account( // Keep Concord channel metadata (community name/icon, membership) live across the whole // app — not just the hub screen — so the Messages tab renders each channel's community - // chip, and per-community bans apply, as soon as a Control Plane folds. + // chip, and per-community bans apply, as soon as a Control Plane folds. The revision bumps + // on every ingested message, so sample() coalesces bursts into at most one full re-index + // per window instead of re-scanning every channel's notes per message. scope.launch { - concordSessions.revision.collect { refreshConcordChannelIndex() } + @OptIn(kotlinx.coroutines.FlowPreview::class) + concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() } } scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 1db7d59e8f..0c69c359fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -24,6 +24,8 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator @@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope +import java.util.concurrent.ConcurrentHashMap class ScreenAuthAccount( val account: Account, @@ -87,10 +90,13 @@ class AuthCoordinator( } val currentLedgers = relayLedgers // Ask the user (only in the ASK case) and fold every account's verdict into one - // decision plus an optional per-relay override to remember. + // decision plus an optional per-relay override to remember. When this relay hosts our + // Concord planes, never block the derived stream-key AUTH behind a user prompt: skip + // the user-auth ASK (DISMISS) so we return the stream AUTHs immediately instead of + // waiting on — or being dropped by — a dialog the user may never answer. val outcome = AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { - promptBus.requestDecision(relayUrl, context.purposes) + if (streamAuths.isNotEmpty()) UserAuthChoice.DISMISS else promptBus.requestDecision(relayUrl, context.purposes) } outcome.remember?.let { decision -> currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) @@ -146,7 +152,9 @@ class AuthCoordinator( if (secrets.isEmpty()) return emptyList() return secrets.mapNotNull { secret -> try { - NostrSignerSync(KeyPair(privKey = secret)).sign(authTemplate) + // Cache the signer by secret so we don't re-derive the secp256k1 keypair for every + // plane on every relay challenge/reconnect. + streamSigners.getOrPut(secret.toHexKey()) { NostrSignerSync(KeyPair(privKey = secret)) }.sign(authTemplate) } catch (e: Exception) { Log.e("AuthCoordinator", "Failed to sign a Concord stream-key AUTH", e) null @@ -154,6 +162,9 @@ class AuthCoordinator( } } + // stream secret (hex) -> its local signer. Bounded by joined communities × channels. + private val streamSigners = ConcurrentHashMap() + fun destroy() { receiver.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 3cc84fe57f..4291038c01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -434,15 +434,6 @@ open class ChannelNewMessageViewModel : private suspend fun createTemplate(): EventTemplate? { val channel = channel ?: return null - // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the - // channel type; NIP-29 groups additionally carry the `h` tag so the relay scopes it. - val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event - if (minichatParent != null) { - return CommentEvent.replyBuilder(message.text.toString(), EventHintBundle(minichatParent, channel.relays().firstOrNull())) { - if (channel is RelayGroupChannel) hTag(channel.groupId.id) - } - } - val messageText = message.text.toString() val tagger = NewMessageTagger( @@ -463,6 +454,25 @@ open class ChannelNewMessageViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) contentWarningDescription else null val localExpirationDate = if (wantsExpirationDate) expirationDate else null + // A minichat reply is a kind-1111 thread comment rooted at the parent, independent of the + // channel type (NIP-29 groups additionally carry the `h` tag). It carries the same mention/ + // hashtag/quote/emoji/attachment enrichment an inline message does — built from tagger.message, + // not the raw text — so replying in a thread never silently drops any of them. + val minichatParent = replyTo.value?.takeIf { replyMode.value == ReplyMode.MINICHAT }?.event + if (minichatParent != null) { + return CommentEvent.replyBuilder(tagger.message, EventHintBundle(minichatParent, channelRelays.firstOrNull())) { + if (channel is RelayGroupChannel) hTag(channel.groupId.id) + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + contentWarningReason?.let { contentWarning(it) } + localExpirationDate?.let { expiration(it) } + geoHash?.let { geohash(it) } + emojis(emojis) + imetas(usedAttachments) + } + } + return when { channel is PublicChatChannel -> { val replyingToEvent = replyTo.value?.toEventHint() From ba0e6a0391f384454d063d6c3aca095753a9171d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:21:44 +0000 Subject: [PATCH 069/115] feat(chat): self-sufficient, reactive minichat + Concord screens; keep minichats off DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Concord/minichat screen now stands on its own regardless of how it's reached: - MinichatScreen mounts its own datasource (the Concord plane subscription when the root is a Concord message; a relay reply REQ for public chats) and drives its list from a reactive MinichatFeedViewModel, so new replies arrive live and auto-scroll into view even when opened via deep link. Adds a LazyListState that scrolls to the newest reply as they land. - ConcordMembersScreen and ConcordEditScreen now mount the plane subscription too (previously they showed nothing when opened directly), and all three community screens (channel list, members, edit) re-resolve sessionFor(communityId) on each session revision instead of caching it once — so a deep link that lands before the community has folded picks it up as soon as it does. Minichats are deliberately kept OFF NIP-17 DMs: the "N replies" chip is gated to the chat types where minichats are wired (Concord, NIP-28, NIP-29), since most clients don't render kind-1111 replies in a DM view. The DM composer already has no thread toggle, so DMs neither create nor surface minichats. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 16 ++++- .../chats/minichat/MinichatFeedViewModel.kt | 70 +++++++++++++++++++ .../loggedIn/chats/minichat/MinichatScreen.kt | 47 +++++++++---- .../concord/ConcordChannelListScreen.kt | 4 +- .../concord/ConcordEditScreen.kt | 9 ++- .../concord/ConcordMembersScreen.kt | 11 ++- 6 files changed, 138 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index 96b3dafb98..f8044fad67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -52,6 +52,9 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteMinichatReplyCount import com.vitorpamplona.amethyst.ui.components.LocalInlineQuoteRenderer @@ -400,8 +403,11 @@ private fun MessageBubbleLines( /** * A chip on a chat message's action row showing how many kind-1111 thread ("minichat") * replies it has; tapping opens that thread. Shown only when there is at least one — an - * inline reply is an ordinary message and isn't counted. Shared across every chat type - * (Concord, NIP-28, NIP-29, DMs), since they all render through this row. + * inline reply is an ordinary message and isn't counted. + * + * Only shown where minichats are actually wired: the public chats (Concord, NIP-28, NIP-29). + * NIP-17 DMs are deliberately excluded — most clients don't render kind-1111 replies in a DM + * view, so a thread there would be a dead end. */ @Composable private fun MinichatReplyChip( @@ -409,6 +415,12 @@ private fun MinichatReplyChip( accountViewModel: AccountViewModel, nav: INav, ) { + val supportsMinichat = + remember(note) { + note.inGatherers?.any { it is ConcordChannel || it is PublicChatChannel || it is RelayGroupChannel } == true + } + if (!supportsMinichat) return + val count by observeNoteMinichatReplyCount(note, accountViewModel) if (count > 0) { Spacer(StdHorzSpacer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt new file mode 100644 index 0000000000..fe21faf8bc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatFeedViewModel.kt @@ -0,0 +1,70 @@ +/* + * 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.minichat + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn + +/** + * The reactive read-model of a message's minichat: the root's kind-1111 [CommentEvent] + * thread replies, oldest-first, filtered by the account's block/ban rules. + * + * Driven off the root [Note]'s replies flow, so it recomputes whenever a reply arrives — + * from the plane (Concord), a relay subscription (public chats), or the local echo of the + * user's own send. Wherever the minichat is opened from, the list stays live. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MinichatFeedViewModel( + val rootNote: Note, + val account: Account, +) : ViewModel() { + val replies: StateFlow> = + rootNote + .flow() + .replies.stateFlow + .mapLatest { collectReplies() } + .flowOn(Dispatchers.Default) + .stateIn(viewModelScope, SharingStarted.Eagerly, collectReplies()) + + private fun collectReplies(): List = + rootNote.replies + .filter { it.event is CommentEvent && account.isAcceptable(it) } + .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) + + class Factory( + val rootNote: Note, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = MinichatFeedViewModel(rootNote, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 6c9d917be6..405505252b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -27,6 +27,7 @@ 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.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material3.ExperimentalMaterial3Api @@ -38,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -46,21 +48,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -71,9 +76,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon * posts a kind-1111 rooted at that message (flat — replying inside a minichat doesn't * spawn sub-threads). Opened from the "N replies" chip on any chat message. * - * It reuses [ChatroomMessageCompose] so the bubbles look exactly like the main chat. - * Observing the root's replies also loads them from relays for public chats; Concord's - * thread replies keep arriving over the account-wide plane ingestion. + * Self-sufficient regardless of where it's opened from: it mounts its own datasource so + * replies keep flowing (the Concord plane subscription when the root is a Concord message; + * a relay reply subscription for public chats), and drives the list from a reactive + * [MinichatFeedViewModel] so new replies appear (and auto-scroll into view) live. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -83,15 +89,28 @@ fun MinichatScreen( nav: INav, ) { val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) } + val isConcord = remember(rootNote) { rootNote.inGatherers?.any { it is ConcordChannel } == true } - // Loads + reacts to the root's replies (kind-1111 among them). - val replyState by observeNoteReplies(rootNote, accountViewModel) - val replies = - remember(replyState) { - rootNote.replies - .filter { it.event is CommentEvent } - .sortedWith(compareBy({ it.createdAt() ?: 0L }, { it.idHex })) - } + // Datasource: keep the thread replies flowing no matter the entry point. Concord replies + // arrive over the channel plane; public-chat (NIP-28/NIP-29) replies over a relay REQ for + // this message's kind-1111 children (a no-op for Concord). + if (isConcord) { + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + } + EventFinderFilterAssemblerSubscription(rootNote, accountViewModel) + + val feedViewModel: MinichatFeedViewModel = + viewModel( + key = rootId + "MinichatFeedViewModel", + factory = MinichatFeedViewModel.Factory(rootNote, accountViewModel.account), + ) + val replies by feedViewModel.replies.collectAsStateWithLifecycle() + + val listState = rememberLazyListState() + // Auto-scroll to the newest reply as they arrive (root is item 0, replies follow). + LaunchedEffect(replies.size) { + if (replies.isNotEmpty()) listState.animateScrollToItem(replies.size) + } val composer = remember { TextFieldState() } val scope = rememberCoroutineScope() @@ -111,7 +130,7 @@ fun MinichatScreen( }, ) { padding -> Column(Modifier.fillMaxHeight().padding(padding)) { - LazyColumn(Modifier.fillMaxWidth().weight(1f, true)) { + LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f, true)) { item("root") { ChatroomMessageCompose( baseNote = rootNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index d75ce8185a..9f93988af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -82,7 +82,9 @@ fun ConcordChannelListScreen( ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) val account = accountViewModel.account - val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + // Re-resolve on each revision so a deep link that lands before the session exists picks it up. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } val state by (session?.state ?: remember { kotlinx.coroutines.flow.MutableStateFlow(null) }) .collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 3fe74570ab..888b858648 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -70,7 +71,13 @@ fun ConcordEditScreen( nav: INav, ) { val account = accountViewModel.account - val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + + // Self-sufficient: mount the plane subscription so a deep link folds the community, and + // re-resolve the session on each revision so it resolves once the session exists. + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() val name = remember { mutableStateOf("") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 5ff00e5e4c..b61332bfc9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions @@ -80,7 +81,15 @@ fun ConcordMembersScreen( nav: INav, ) { val account = accountViewModel.account - val session = remember(account, communityId) { account.concordSessions.sessionFor(communityId) } + + // Self-sufficient: mount the Concord plane subscription so the community folds even when this + // screen is opened directly (deep link), not only from the hub. + ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + + // Re-resolve the session as sessions are created/folded (revision-keyed), so a deep link that + // lands before the community's session exists still picks it up once it does. + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() val myPubKey = account.signer.pubKey From 648e9f427a59177c64cf3c51e42f5e598bdf5acb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:58:36 +0000 Subject: [PATCH 070/115] feat(concord): wire CORD-06 refounding for real member removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ban is only a soft removal — the member still holds the room key and can decrypt everything; clients just decline to show their posts. This wires CORD-06 Refounding: the hard removal that rotates the community_root so a removed member's key stops working for anything sent afterwards. Quartz: - ConcordKeyDerivation: baseRekeyAddress / channelRekeyAddress (the rekey stream addresses) and epochKeyCommitment (prevcommit, CORD-02 A.5). - ConcordRekey: signer-based blobForSigner / findNewKeyWithSigner so a bunker account opens its blob with a single nip44Decrypt. - ConcordRefounding: compactControlPlane (re-wrap each head edition's original plaintext seal under the new root, preserving signatures), buildBaseRekeyWraps, build, findNewRoot. OpenedStreamEvent now carries the inner seal for compaction. ConcordRefoundingTest. Commons: - ConcordActions: guestbookPlane / nextBaseRekeyPlane, buildGuestbookJoin / guestbookMembers, buildRefounding, openBaseRekey. - ConcordCommunitySession folds the Guestbook plane into members (the recipient set), buffers inbound base-rekey wraps, exposes controlPlaneWraps, and AUTHs to + subscribes the Guestbook and next-epoch base-rekey planes. - ConcordSessionRegistry.sync rebuilds a session when its entry's root/epoch changed; ConcordSubscriptionPlanner.auxiliaryPlaneSubs REQs the new planes. Amethyst: - Account announces a Guestbook JOIN on create/join; refoundConcordCommunity (owner / BAN-holder) bans + rolls + publishes + persists; drainConcordRekeys adopts an inbound rotation from an authorized rotator; adoptConcordRoot persists the new root (prior kept as a HeldRoot) and re-seeds the new epoch's Guestbook, guarded against double-adopt. - AccountViewModel.removeConcordMember; ConcordMembersScreen "Remove from community" action + confirmation dialog. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../plans/2026-07-13-cord06-refounding.md | 83 +++++++ .../vitorpamplona/amethyst/model/Account.kt | 180 +++++++++++++++- .../ui/screen/loggedIn/AccountViewModel.kt | 12 ++ .../concord/ConcordMembersScreen.kt | 52 ++++- .../ConcordChannelFilterAssembler.kt | 1 + amethyst/src/main/res/values/strings.xml | 4 + .../commons/actions/ConcordActions.kt | 103 +++++++++ .../actions/ConcordSubscriptionPlanner.kt | 19 ++ .../model/concord/ConcordCommunitySession.kt | 67 +++++- .../model/concord/ConcordSessionRegistry.kt | 11 +- .../commons/actions/ConcordActionsTest.kt | 53 +++++ .../concord/cord06Rekey/ConcordRefounding.kt | 203 ++++++++++++++++++ .../concord/cord06Rekey/ConcordRekey.kt | 50 +++++ .../concord/crypto/ConcordKeyDerivation.kt | 45 ++++ .../quartz/concord/crypto/ConcordLabels.kt | 3 + .../concord/envelope/ConcordStreamEnvelope.kt | 10 +- .../cord06Rekey/ConcordRefoundingTest.kt | 151 +++++++++++++ 17 files changed, 1031 insertions(+), 16 deletions(-) create mode 100644 amethyst/plans/2026-07-13-cord06-refounding.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt diff --git a/amethyst/plans/2026-07-13-cord06-refounding.md b/amethyst/plans/2026-07-13-cord06-refounding.md new file mode 100644 index 0000000000..a74e8ffd53 --- /dev/null +++ b/amethyst/plans/2026-07-13-cord06-refounding.md @@ -0,0 +1,83 @@ +# CORD-06 Refounding — real member removal for Concord + +## Problem + +Concord membership is key possession: a banned member (CORD-04 banlist) still +holds the community's `community_root`, so every client just *declines to show* +their posts — they can still decrypt everything. That is a soft removal. CORD-06 +adds the hard removal: rotate the key so a removed member's key stops working for +anything sent afterwards. + +The quartz crypto for the kind-3303 rekey blob (`ConcordRekey`, `RekeyBlob`) +already existed and was tested, but nothing in the app called it. This wires the +whole path — build, publish, receive, persist, UI — around a **Refounding** +(whole-community rotation), the removal that matters while Amethyst supports only +public channels (a per-channel rekey needs private channels, not built yet). + +## What a Refounding does (CORD-06 §3) + +1. Ban the removed members on the current Control Plane (so the compacted snapshot + carries the ban). +2. Roll `community_root` to a fresh random 32 bytes at `rootEpoch + 1`. Public + channels + the Control/Guestbook planes all derive from the root, so rolling it + rotates every plane at once. +3. Republish the **compacted** Control Plane under the new root — keep only each + entity's head edition and re-wrap its *original plaintext seal*, so the original + authors' signatures survive re-encryption (a fresh joiner verifies the slim + state exactly as it verified the full chain). +4. Mint per-recipient kind-3303 rekey blobs delivering the new root to every + retained member, sealed + addressed under the **prior** root on the + `base-rekey-pseudonym(prior_root, community_id, new_epoch)` address — which every + current member precomputes, so they receive it live. A removed member gets no + blob and can never derive the new root. + +## Layers + +- **quartz** `concord/cord06Rekey/` + - `ConcordKeyDerivation`: `baseRekeyAddress` / `channelRekeyAddress` (the rekey + stream addresses), `epochKeyCommitment` (`prevcommit`, CORD-02 §A.5). + - `ConcordRekey`: signer-based `blobForSigner` / `findNewKeyWithSigner` (bunker + accounts open a blob with one `nip44Decrypt`, no raw key). + - `ConcordRefounding`: `compactControlPlane`, `buildBaseRekeyWraps`, `build` + (whole refounding), `findNewRoot` (receive: verify scope/epoch/continuity, find + my blob). `OpenedStreamEvent` now also carries the inner `seal` so compaction + can re-wrap it. Tests in `ConcordRefoundingTest`. +- **commons** + - `ConcordActions`: `guestbookPlane` / `nextBaseRekeyPlane`, `buildGuestbookJoin` + / `guestbookMembers`, `buildRefounding`, `openBaseRekey`. + - `ConcordCommunitySession`: folds the Guestbook plane into `members` + (the recipient set), buffers inbound base-rekey wraps (`pendingBaseRekeyWraps`), + exposes `controlPlaneWraps` for compaction, and AUTHs to + subscribes the + Guestbook and next-epoch base-rekey planes (`streamKeys`, `subscribeAddresses`). + - `ConcordSessionRegistry.sync`: rebuilds a session when its entry's root/epoch + changed — the session is a pure function of its entry, so adopting a new root is + just a persisted entry swap. + - `ConcordSubscriptionPlanner.auxiliaryPlaneSubs`: REQs the Guestbook + next + base-rekey planes for every joined community. +- **amethyst** + - `Account`: announces a Guestbook JOIN on create/join (`announceConcordGuestbookJoin`) + so members are visible to a future rotator; `refoundConcordCommunity` (owner / + BAN-holder) bans + rolls + publishes + persists; `drainConcordRekeys` (revision + tick) adopts an inbound rotation from an authorized rotator; `adoptConcordRoot` + persists the new root (prior root kept as a `HeldRoot`) and re-seeds the new + epoch's Guestbook, guarded against double-adopt. + - `AccountViewModel.removeConcordMember`; `ConcordMembersScreen` "Remove from + community" action + confirm dialog, gated exactly like Ban. + +## Recipient set + +The rotator re-keys **Guestbook membership ∪ the privileged roster ∪ self**, minus +the removed and the already-banned. The Guestbook is best-effort/off-consensus, so +a member who joined but whose Guestbook JOIN hasn't propagated to the rotator would +be missed and locked out — the accepted trade for a serverless, key-possession +membership model. Adopting a new root re-announces the Guestbook JOIN at the new +epoch so cascading removals keep a live membership. + +## Known limitations / follow-ups + +- No explicit "you were removed" detection: a removed member simply stops receiving + new content (their old-epoch keys still read history). CORD-06's "held all n + chunks, none is mine ⇒ removed" self-eviction is not implemented. +- Per-channel rekey (single private channel) is not wired — needs private channels. +- Race convergence (two rotators, same epoch, lexicographically-lowest-key wins) is + not implemented; single-rotator (owner/admin) refounding is the supported path. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 01cbbf42fa..e670738245 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -144,6 +144,7 @@ import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity @@ -1893,8 +1894,33 @@ class Account( suspend fun unfollow(channel: RelayGroupChannel) = sendMyPublicAndPrivateOutbox(relayGroupList.unfollow(channel)) - /** Add a joined Concord community (secret-bearing entry) to the private kind-13302 list. */ - suspend fun joinConcordCommunity(entry: ConcordCommunityListEntry) = sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) + /** + * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * list, and announce a self-signed Guestbook JOIN so this member is visible to + * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). + */ + suspend fun joinConcordCommunity( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ) { + sendMyPublicAndPrivateOutbox(concordChannelList.follow(entry)) + announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) + } + + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ + private suspend fun announceConcordGuestbookJoin( + entry: ConcordCommunityListEntry, + inviteCreator: HexKey?, + inviteLabel: String?, + ) { + if (!isWriteable()) return + val guestbook = ConcordActions.guestbookPlane(entry.root.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(signer, guestbook, TimeUtils.now(), inviteCreator, inviteLabel) + concordSessions.ingest(wrap) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) client.publish(wrap, relays) + } /** * Create a new Concord community: mint its genesis (metadata + #general), @@ -2261,6 +2287,150 @@ class Account( return true } + // ── Concord refounding / rekey (CORD-06) ────────────────────────────────── + // A ban is a soft removal — the banned member still holds the room key and can + // still decrypt traffic; every client just declines to *show* their posts. A + // Refounding is the hard removal: it rotates the community_root, so a removed + // member's key stops working for anything published afterwards. + + /** + * Remove [removed] from the community absolutely (CORD-06 Refounding): ban them, + * roll the `community_root`, re-key every retained member (Guestbook membership ∪ + * the privileged roster ∪ self) via kind-3303 blobs, and republish the compacted + * Control Plane under the new root. A removed member keeps the prior root (so + * their history stays readable) but receives no blob, so they can never decrypt + * anything published after the rotation. + * + * Requires ownership or the BAN permission; returns false otherwise (or if the + * community isn't joined/writeable, or a target is the owner). + */ + suspend fun refoundConcordCommunity( + communityId: String, + removed: Set, + ): Boolean { + if (!isWriteable()) return false + val session = concordSessions.sessionFor(communityId) ?: return false + val state = session.state.value ?: return false + val authority = state.authority + val iCanBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN) + if (!iCanBan) return false + val removedLower = removed.mapTo(HashSet()) { it.lowercase() } + if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false + + // 1. Ban the removed members on the current Control Plane so the compacted snapshot — + // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally + // first, so each subsequent edition chains onto the updated banlist head. + for (target in removedLower) { + val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, banWrap) + } + + // 2. Recipient set: everyone we're keeping — Guestbook joins ∪ roster ∪ self, minus the + // removed and the already-banned. + val recipients = + (session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removedLower) + removeAll(authority.bannedMembers()) + }.toList() + + // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. + val entry = session.entry + val newRoot = RandomInstance.bytes(32) + val build = + ConcordActions.buildRefounding( + rotatorSigner = signer, + communityId = communityId, + priorRoot = entry.root.hexToByteArray(), + newRoot = newRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = session.controlPlaneWraps(), + priorControlKey = session.controlPlaneKey(), + recipientsXOnly = recipients, + createdAt = TimeUtils.now(), + ) + + // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs + // (the key that unlocks it) to the community relays. + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isNotEmpty()) { + build.controlWraps.forEach { client.publish(it, publishTo) } + build.rekeyWraps.forEach { client.publish(it, publishTo) } + } + + // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + adoptConcordRoot(entry, newRoot, build.newEpoch) + return true + } + + // Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered + // in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not + // adopted — and re-published — twice on successive revision ticks. + private val adoptedConcordRotations = java.util.Collections.synchronizedSet(HashSet()) + + /** + * Persist a rotated access root/epoch for [entry], keeping the prior root as a + * [HeldRoot], and re-announce our Guestbook membership at the new epoch so the + * fresh epoch's Guestbook re-seeds (a later Refounding re-keys that membership — + * without this, cascading removals would lose everyone but the roster). No-op if + * this exact rotation was already adopted. + */ + private suspend fun adoptConcordRoot( + entry: ConcordCommunityListEntry, + newRoot: ByteArray, + newEpoch: Long, + ) { + if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch } + val next = + ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = newRoot.toHexKey(), + rootEpoch = newEpoch, + heldRoots = held, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + ) + sendMyPublicAndPrivateOutbox(concordChannelList.follow(next)) + announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) + } + + /** + * Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for + * each joined community, look for our new root among the kind-3303 wraps seen at + * our next base-rekey address. If a role-authorized rotator (owner or a current + * BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session + * rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap + * never re-triggers. Called on every Concord revision tick. + */ + private suspend fun drainConcordRekeys() { + if (!isWriteable()) return + for (session in concordSessions.sessions()) { + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = signer, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + ) ?: continue + if (received.newEpoch <= entry.rootEpoch) continue + val authority = session.state.value?.authority ?: continue + val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN) + if (!authorized) continue + adoptConcordRoot(entry, received.newRoot, received.newEpoch) + } + } + /** * Replace the community metadata (name / icon / description / relays) with a new * Control-Plane edition. Honored on fold only when this account holds @@ -4853,7 +5023,11 @@ class Account( // per window instead of re-scanning every channel's notes per message. scope.launch { @OptIn(kotlinx.coroutines.FlowPreview::class) - concordSessions.revision.sample(500).collect { refreshConcordChannelIndex() } + concordSessions.revision.sample(500).collect { + refreshConcordChannelIndex() + // A revision also bumps when a base-rotation rekey lands; adopt ours if present. + runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) } + } } scope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index a1c0b8847c..887bf0915a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -623,6 +623,18 @@ class AccountViewModel( if (ban) account.banConcordMember(communityId, member) else account.unbanConcordMember(communityId, member) } + /** + * Remove [member] from [communityId] absolutely (CORD-06 Refounding): rotate the + * community key so the member's key stops working for anything sent afterwards. + * Heavier than a ban (re-keys every retained member); owner / BAN-holder only. + */ + fun removeConcordMember( + communityId: String, + member: HexKey, + ) = launchSigner { + account.refoundConcordCommunity(communityId, setOf(member)) + } + /** Pull the account's Concord community list from the stock + own relays (Concord hub bootstrap). */ fun importConcordCommunities() = viewModelScope.launch(Dispatchers.IO) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index b61332bfc9..e32225d6ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -38,6 +39,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -172,7 +174,20 @@ private fun ConcordMemberRow( // never against the owner or yourself. A banned user only offers "unban". val canToggleAdmin = viewerIsOwner && !isOwnerTarget && !isBanned && !isSelf val canBan = viewerCanBan && !isOwnerTarget && !isSelf - val hasMenu = canToggleAdmin || canBan + // Hard removal (CORD-06 Refounding) rotates the community key; same authority as ban. + val canRemove = viewerCanBan && !isOwnerTarget && !isSelf + val hasMenu = canToggleAdmin || canBan || canRemove + + var confirmRemove by remember { mutableStateOf(false) } + if (confirmRemove) { + ConcordRemoveMemberDialog( + onConfirm = { + accountViewModel.removeConcordMember(communityId, entry.pubkey) + confirmRemove = false + }, + onDismiss = { confirmRemove = false }, + ) + } androidx.compose.foundation.layout.Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), @@ -212,6 +227,20 @@ private fun ConcordMemberRow( }, ) } + if (canRemove) { + DropdownMenuItem( + text = { + Text( + stringRes(R.string.concord_members_remove), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + confirmRemove = true + expanded = false + }, + ) + } } } } @@ -239,6 +268,27 @@ private fun MemberBadge(membership: ConcordMembership) { } } +/** Confirms a hard removal — spells out that it rotates the community key (CORD-06). */ +@Composable +private fun ConcordRemoveMemberDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.concord_members_remove_title)) }, + text = { Text(stringRes(R.string.concord_members_remove_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringRes(R.string.concord_members_remove_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + private class RosterEntry( val pubkey: HexKey, val membership: ConcordMembership, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index 25543b1888..1b20c93891 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -78,6 +78,7 @@ class ConcordChannelSubAssembler( // kind-1059 filters lives in the shared planner. val subs = ArrayList() subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) + subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries) for (entry in entries) { val state = account.concordSessions diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 553edca79d..cd6431329c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -337,6 +337,10 @@ Remove admin Ban Unban + Remove from community + Remove member? + This rotates the community\'s encryption key so this member can no longer read anything sent afterwards. Everyone else is re-keyed automatically. This can\'t be undone. + Remove Owner Admin Banned diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index ecee9a7b9b..45e9ac2c90 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.Guestbook +import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction +import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys @@ -33,6 +36,9 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.MintedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.ParsedInviteLink import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.RefoundingBuild import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -78,6 +84,24 @@ object ConcordActions { rootEpoch: Long, ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + /** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */ + fun guestbookPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.guestbookPlaneKey(communityRoot, communityId, rootEpoch) + + /** + * The base-rotation rekey address a member watches to receive the *next* epoch's + * Refounding (CORD-06 §2): `base-rekey-pseudonym(current_root, community_id, + * rootEpoch + 1)`. Precomputed from the root the member already holds. + */ + fun nextBaseRekeyPlane( + communityRoot: ByteArray, + communityId: ByteArray, + rootEpoch: Long, + ): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1) + // ---- relay filters (what to REQ) ----------------------------------------- /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ @@ -252,4 +276,83 @@ object ConcordActions { /** Derives the control plane described by a redeemed [invite] so the joiner can read it. */ fun controlPlaneFor(invite: CommunityInvite): GroupKey = controlPlane(invite.communityRoot.hexToByteArray(), invite.communityId.hexToByteArray(), invite.rootEpoch) + + // ---- guestbook (CORD-02 §5) ---------------------------------------------- + + /** + * Builds a self-signed Guestbook JOIN (kind 3306) wrap on the community's + * Guestbook Plane. Membership is off-consensus best-effort presence, but it is + * the member-visible roster a Refounding rotates keys to (CORD-06), so a client + * announces one on create/join to be re-keyed on future removals. + */ + suspend fun buildGuestbookJoin( + memberSigner: NostrSigner, + guestbook: GroupKey, + createdAt: Long, + inviteCreator: HexKey? = null, + inviteLabel: String? = null, + ): Event { + val rumor = Guestbook.join(memberSigner.pubKey, createdAt, inviteCreator = inviteCreator, inviteLabel = inviteLabel) + return ConcordStreamEnvelope.wrap(rumor, guestbook, memberSigner, encrypted = true, createdAt = createdAt) + } + + /** Opens the guestbook [wraps] into their live membership set (joins minus later leaves). */ + fun guestbookMembers( + wraps: List, + guestbook: GroupKey, + ): Set { + val latest = HashMap() + for (wrap in wraps) { + val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue + val entry = Guestbook.parse(rumor) ?: continue + val prev = latest[entry.member.lowercase()] + if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry + } + return latest.values.filter { it.action == GuestbookAction.JOIN }.mapTo(HashSet()) { it.member.lowercase() } + } + + // ---- refounding / rekey (CORD-06) ---------------------------------------- + + /** + * Builds a whole-community Refounding (CORD-06 §3): the compacted Control Plane + * re-sealed under [newRoot] plus the base-rotation rekey blobs delivering + * [newRoot] to [recipientsXOnly]. Pure — the caller sources the recipient set + * and owns publish + persistence. + */ + suspend fun buildRefounding( + rotatorSigner: NostrSigner, + communityId: HexKey, + priorRoot: ByteArray, + newRoot: ByteArray, + rootEpoch: Long, + priorControlWraps: List, + priorControlKey: GroupKey, + recipientsXOnly: List, + createdAt: Long, + ): RefoundingBuild = + ConcordRefounding.build( + rotatorSigner = rotatorSigner, + communityId = communityId.hexToByteArray(), + priorRoot = priorRoot, + newRoot = newRoot, + rootEpoch = rootEpoch, + priorControlWraps = priorControlWraps, + priorControlKey = priorControlKey, + recipientsXOnly = recipientsXOnly, + createdAt = createdAt, + ) + + /** + * Receives an inbound base rotation for the member behind [recipientSigner]: + * finds the delivered new root across the buffered kind-3303 [wraps], verifying + * scope, epoch and continuity against the [priorRoot] the member holds. Returns + * the new root + rotator (for the caller to authorize) or null if not re-keyed. + */ + suspend fun openBaseRekey( + wraps: List, + baseRekey: GroupKey, + recipientSigner: NostrSigner, + priorRoot: ByteArray, + rootEpoch: Long, + ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, priorRoot, rootEpoch) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 72edecb009..bd9fe78212 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -62,6 +62,25 @@ object ConcordSubscriptionPlanner { ConcordPlaneSub(channelId = null, pubKeyHex = cp.publicKeyHex, relays = normalize(e.relays)) } + /** + * The off-channel planes every joined community subscribes to upfront (known + * from the entry alone): the Guestbook Plane (membership motions) and the + * next-epoch base-rekey address (so an inbound Refounding is received live, + * CORD-06). Both are kind-1059 wraps authored by their derived stream address. + */ + fun auxiliaryPlaneSubs(entries: List): List = + entries.flatMap { e -> + val root = e.root.hexToByteArray() + val communityId = e.id.hexToByteArray() + val relays = normalize(e.relays) + val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) + val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) + listOf( + ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays), + ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays), + ) + } + /** Chat-plane subscriptions for every live channel in a folded community [state]. */ fun channelPlaneSubs( entry: ConcordCommunityListEntry, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 3c983f7085..22f920c792 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -66,14 +66,32 @@ class ConcordCommunitySession( private val controlPlaneKey: GroupKey = ConcordActions.controlPlane(root, communityIdBytes, entry.rootEpoch) + /** The Guestbook Plane at this epoch — where member join/leave motions ride (CORD-02 §5). */ + private val guestbookKey: GroupKey = ConcordActions.guestbookPlane(root, communityIdBytes, entry.rootEpoch) + + /** + * The base-rotation rekey address for the *next* epoch (CORD-06 §2). A member + * precomputes it from the root they already hold so an inbound Refounding — which + * delivers the next root here — is received live rather than only on re-open. + */ + private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) + /** The Control Plane stream address to subscribe to (known from the entry alone). */ val controlPlaneAddress: HexKey get() = controlPlaneKey.publicKeyHex + /** The Guestbook Plane stream address to subscribe to (known from the entry alone). */ + val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex + + /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ + val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex + private val lock = KmpLock() // Deduped inbound wraps. private val controlWraps = LinkedHashMap() private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) + private val guestbookWraps = LinkedHashMap() + private val baseRekeyWraps = LinkedHashMap() // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. private var channelKeysByAddress = HashMap>() @@ -81,22 +99,39 @@ class ConcordCommunitySession( private val _state = MutableStateFlow(null) val state: StateFlow = _state + private val _members = MutableStateFlow>(emptySet()) + + /** The live Guestbook membership set (self-signed joins minus later leaves). */ + val members: StateFlow> = _members + /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + /** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */ + fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey + + /** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */ + fun pendingBaseRekeyWraps(): List = lock.withLock { baseRekeyWraps.values.toList() } + /** - * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus - * one per folded channel. These are the identities a NIP-42 relay must see the - * connection authenticate as (kind 22242) to serve the wraps — a Concord wrap is - * authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the - * member is neither author nor recipient and the relay refuses unless we AUTH as the - * stream key itself. + * Every stream key whose kind-1059 wraps this session reads: the Control Plane, the + * Guestbook Plane, one per folded channel, and the next-epoch base-rekey address. + * These are the identities a NIP-42 relay must see the connection authenticate as + * (kind 22242) to serve the wraps — a Concord wrap is authored by the stream key and + * `p`-tagged to a throwaway ephemeral key, so the member is neither author nor + * recipient and the relay refuses unless we AUTH as the stream key itself. */ - fun streamKeys(): List = lock.withLock { listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second } } + fun streamKeys(): List = + lock.withLock { + listOf(controlPlaneKey, guestbookKey, nextBaseRekeyKey) + channelKeysByAddress.values.map { it.second } + } /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } + /** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */ + fun controlPlaneWraps(): List = lock.withLock { controlWraps.values.toList() } + /** The Control Plane key, for authoring moderation editions. */ fun controlPlaneKey(): GroupKey = controlPlaneKey @@ -120,6 +155,19 @@ class ConcordCommunitySession( refold() return true } + guestbookAddress -> { + lock.withLock { + if (guestbookWraps.put(wrap.id, wrap) != null) return true // dup + } + refoldGuestbook() + return true + } + nextBaseRekeyAddress -> { + // Buffer only — decrypting a base-rotation blob needs the account signer, so the + // app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. + lock.withLock { baseRekeyWraps[wrap.id] = wrap } + return true + } else -> { val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return false val (channelIdHex, _) = channelRef @@ -149,6 +197,11 @@ class ConcordCommunitySession( for (channelIdHex in folded.channels.keys) reprojectChannel(channelIdHex) } + private fun refoldGuestbook() { + val wraps = lock.withLock { guestbookWraps.values.toList() } + _members.value = ConcordActions.guestbookMembers(wraps, guestbookKey) + } + private fun reprojectChannel(channelIdHex: HexKey) { val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 275907af8e..60b62b08e0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -66,10 +66,15 @@ class ConcordSessionRegistry( val wanted = entries.associateBy { it.id } // Drop sessions for communities we've left. sessions.keys.retainAll(wanted.keys) - // Add sessions for newly-joined communities. + // Add sessions for newly-joined communities, and rebuild a session whose access + // material changed under it — a Refounding rotates the community_root and bumps + // the epoch (CORD-06), so the persisted entry now describes a different set of + // planes; the session is a pure function of its entry, so we recreate it to + // re-derive every address and re-fold under the new root. val created = mutableSetOf() for ((id, entry) in wanted) { - if (id !in sessions) { + val existing = sessions[id] + if (existing == null || existing.entry.root != entry.root || existing.entry.rootEpoch != entry.rootEpoch) { sessions[id] = ConcordCommunitySession(entry, myPubKey, onRumor) created += id } @@ -87,6 +92,8 @@ class ConcordSessionRegistry( val out = HashSet() for (session in sessions.values) { out += session.controlPlaneAddress + out += session.guestbookAddress + out += session.nextBaseRekeyAddress out += session.channelAddresses() } out diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index 173634d2db..15c73f51d0 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -78,5 +78,58 @@ class ConcordActionsTest { assertEquals("Nostrichs", state.metadata?.name) } + @Test + fun guestbookJoinFoldsIntoMembership() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example")) + val alice = NostrSignerInternal(KeyPair()) + val bob = NostrSignerInternal(KeyPair()) + val guestbook = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch) + + val joins = + listOf( + ConcordActions.buildGuestbookJoin(alice, guestbook, createdAt = 2L), + ConcordActions.buildGuestbookJoin(bob, guestbook, createdAt = 3L), + ) + val members = ConcordActions.guestbookMembers(joins, guestbook) + assertEquals(setOf(alice.pubKey.lowercase(), bob.pubKey.lowercase()), members) + } + + @Test + fun refoundingReKeysRetainedAndSeversRemoved() = + runTest { + val community = ConcordActions.createCommunity(owner, "Test", createdAt = 1L, relays = listOf("wss://r.example")) + val alice = NostrSignerInternal(KeyPair()) // retained + val carol = NostrSignerInternal(KeyPair()) // removed + + val newRoot = ByteArray(32) { 0x33 } + val build = + ConcordActions.buildRefounding( + rotatorSigner = owner, + communityId = community.communityIdHex, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(owner.pubKey, alice.pubKey), + createdAt = 5L, + ) + + val baseRekey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch) + + val aliceGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, alice, community.communityRoot, community.rootEpoch) + val carolGot = ConcordActions.openBaseRekey(build.rekeyWraps, baseRekey, carol, community.communityRoot, community.rootEpoch) + assertNotNull(aliceGot) + assertEquals(community.rootEpoch + 1, aliceGot.newEpoch) + assertTrue(carolGot == null) + + // The compacted Control Plane folds identically under the new root. + val newControl = ConcordActions.controlPlane(aliceGot.newRoot, community.communityId, aliceGot.newEpoch) + val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey) + assertEquals("Test", state.metadata?.name) + assertTrue(state.channels.isNotEmpty()) + } + private fun ByteArray.toHex(): String = joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt new file mode 100644 index 0000000000..7a7bb34899 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -0,0 +1,203 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler + +/** + * The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current + * Control Plane, compacted to its per-entity head editions and re-sealed under the + * fresh [newRoot] at [newEpoch]) and the [rekeyWraps] (kind-3303 base-rotation + * blobs, sealed under the **prior** root, that deliver [newRoot] to every retained + * member and to nobody else). Publish [controlWraps] first (the new epoch's state) + * then [rekeyWraps] (the key that unlocks it). + */ +class RefoundingBuild( + val newRoot: ByteArray, + val newEpoch: Long, + val controlWraps: List, + val rekeyWraps: List, +) + +/** A retained member's decrypted rekey result: the [newRoot] delivered at [newEpoch] by [rotator]. */ +class ReceivedRefounding( + val newRoot: ByteArray, + val newEpoch: Long, + val rotator: HexKey, +) + +/** + * Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a + * removed member absolutely. Public Channels and the Control/Guestbook planes all + * derive from the root, so rolling it rotates every plane at once; Private Channels + * (independently keyed) are rekeyed separately and are not handled here. + * + * The builder is pure — the caller sources the retained-recipient set (from the + * Guestbook membership minus the removed/banned) and owns publish + persistence. + * All crypto is signer-based so a NIP-46 bunker owner can refound without exposing + * a raw key. + */ +object ConcordRefounding { + /** + * Builds a Refounding: compacts the Control Plane under [newRoot] and mints the + * base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly]. + * + * @param priorRoot the community_root being rotated out (at [rootEpoch]) + * @param newRoot the freshly generated 32-byte community_root + * @param priorControlWraps the current Control Plane's kind-1059 wraps (any subset that folds) + * @param priorControlKey the Control Plane group key at [rootEpoch] + * @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key + */ + suspend fun build( + rotatorSigner: NostrSigner, + communityId: ByteArray, + priorRoot: ByteArray, + newRoot: ByteArray, + rootEpoch: Long, + priorControlWraps: List, + priorControlKey: GroupKey, + recipientsXOnly: List, + createdAt: Long, + ): RefoundingBuild { + val newEpoch = rootEpoch + 1 + val newControlKey = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, newEpoch) + + val controlWraps = compactControlPlane(priorControlWraps, priorControlKey, newControlKey) + + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) + val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() + val rekeyWraps = + buildBaseRekeyWraps( + rotatorSigner = rotatorSigner, + baseRekeyKey = baseRekeyKey, + recipientsXOnly = recipientsXOnly, + newRoot = newRoot, + newEpoch = newEpoch, + prevEpoch = rootEpoch, + prevCommit = prevCommit, + createdAt = createdAt, + ) + + return RefoundingBuild(newRoot, newEpoch, controlWraps, rekeyWraps) + } + + /** + * Compacts [priorWraps] into a slim snapshot re-published under [newControlKey] + * (CORD-06 §3): keep only the head (highest-version) edition per entity and + * re-wrap its **original plaintext seal** — which carries the original author's + * signature — under the new root. Because Control Plane seals are plaintext + * (CORD-02 §5), re-encryption preserves those signatures, so a fresh joiner + * verifies the compacted state exactly as it verified the full chain. + */ + fun compactControlPlane( + priorWraps: List, + priorControlKey: GroupKey, + newControlKey: GroupKey, + ): List { + // entity coordinate -> (head edition, its verified seal) + val heads = HashMap>() + for (wrap in priorWraps) { + val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKey) ?: continue + val edition = ControlEdition.fromRumor(opened.rumor) ?: continue + val coord = edition.entityKind.wire + ":" + edition.entityIdHex + val current = heads[coord] + if (current == null || edition.version > current.first.version) { + heads[coord] = edition to opened.seal + } + } + return heads.values.map { (_, seal) -> ConcordStreamEnvelope.wrapSeal(seal, newControlKey, createdAt = seal.createdAt) } + } + + /** + * Mints the base-rotation rekey blobs delivering [newRoot] to [recipientsXOnly], + * chunked at [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, + * rotator-signed) on the [baseRekeyKey] address so every current member — who + * precomputes that address from the prior root — receives it live. + */ + suspend fun buildBaseRekeyWraps( + rotatorSigner: NostrSigner, + baseRekeyKey: GroupKey, + recipientsXOnly: List, + newRoot: ByteArray, + newEpoch: Long, + prevEpoch: Long, + prevCommit: HexKey, + createdAt: Long, + ): List { + if (recipientsXOnly.isEmpty()) return emptyList() + val blobs = + recipientsXOnly.map { recipient -> + ConcordRekey.blobForSigner(rotatorSigner, recipient.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, newRoot) + } + val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK) + val total = chunks.size + return chunks.mapIndexed { index, chunk -> + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total) + val rumor = RumorAssembler.assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) + ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt) + } + } + + /** + * Receives a base rotation for the member behind [recipientSigner]: opens the + * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), + * verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit` + * continues the [priorRoot] the member holds, and returns the delivered new root + * (with the rotator's real pubkey, so the caller can authorize it against the + * folded roster). Null if no chunk carries this member's blob — which only means + * "removed" once the caller confirms it holds every chunk of the rotation. + */ + suspend fun findNewRoot( + wraps: List, + baseRekeyKey: GroupKey, + recipientSigner: NostrSigner, + priorRoot: ByteArray, + rootEpoch: Long, + ): ReceivedRefounding? { + val newEpoch = rootEpoch + 1 + val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey() + val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() + for (wrap in wraps) { + val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue + val rumor = opened.rumor + if (rumor.kind != ConcordRekey.KIND) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue + + val blobs = ConcordRekey.decodeContent(rumor.content) + val rotatorXOnly = opened.author.hexToByteArray() + val newRoot = ConcordRekey.findNewKeyWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue + return ReceivedRefounding(newRoot, newEpoch, opened.author) + } + return null + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index 0d5a0c1d3c..fed5b15eb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip44Encryption.Nip44 import kotlinx.serialization.builtins.ListSerializer import kotlin.io.encoding.Base64 @@ -107,6 +109,54 @@ object ConcordRekey { const val KIND: Int = 3303 + /** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */ + const val MAX_BLOBS_PER_CHUNK = 120 + + /** + * Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw + * private key, so a NIP-46 bunker rotator can mint blobs without exposing its + * key (the wrap is a single `nip44Encrypt` to the recipient). The locator is + * public-input-only (CORD-06 §2) and needs no signing. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun blobForSigner( + rotatorSigner: NostrSigner, + recipientXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + newKey: ByteArray, + ): RekeyBlob { + val rotatorXOnly = rotatorSigner.pubKey.hexToByteArray() + val locator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val payloadB64 = Base64.Default.encode(RekeyPayload(scopeId, newEpoch, newKey).encode()) + val wrapped = rotatorSigner.nip44Encrypt(payloadB64, recipientXOnly.toHexKey()) + return RekeyBlob(locator, wrapped) + } + + /** + * Finds the recipient's rotated key like [findNewKey], but decrypts the blob via + * [recipientSigner] (bunker-compatible) rather than a raw private key. + */ + @OptIn(ExperimentalEncodingApi::class) + suspend fun findNewKeyWithSigner( + blobs: List, + recipientSigner: NostrSigner, + rotatorXOnly: ByteArray, + scopeId: ByteArray, + newEpoch: Long, + ): ByteArray? { + val recipientXOnly = recipientSigner.pubKey.hexToByteArray() + val myLocator = ConcordKeyDerivation.recipientLocator(rotatorXOnly, recipientXOnly, scopeId, newEpoch).toHexKey() + val blob = blobs.firstOrNull { it.locator == myLocator } ?: return null + return try { + val payload = RekeyPayload.decode(Base64.Default.decode(recipientSigner.nip44Decrypt(blob.wrapped, rotatorXOnly.toHexKey()))) ?: return null + if (!payload.scopeId.contentEquals(scopeId) || payload.epoch != newEpoch) return null + payload.newKey + } catch (_: Exception) { + null + } + } + /** * Finds the recipient's rotated key across the [blobs] of one or more chunks, * or null if they were removed. Computes the recipient's locator, matches it, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index b15b2bc56f..14d694b0cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -225,6 +225,51 @@ object ConcordKeyDerivation { */ fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY)) + // ---- CORD-06 rekey addresses & commitment --------------------------------- + + /** + * The base-rotation rekey address for a Refounding (CORD-06 §2 Subscription): + * `group_key("concord/base-rekey-pseudonym", prior_community_root, community_id, + * new_epoch)`. The rotator publishes the kind-3303 blobs here and every current + * member precomputes it (from the root they already hold at the *next* epoch) to + * receive their new root in real time. Keyed by the **prior** root on purpose so + * the address stays computable by everyone who still holds it. + */ + fun baseRekeyAddress( + priorCommunityRoot: ByteArray, + communityId: ByteArray, + newEpoch: Long, + ): GroupKey = groupKey(ConcordLabels.BASE_REKEY_PSEUDONYM, priorCommunityRoot, communityId, newEpoch) + + /** + * The per-channel rekey address (CORD-06 §2): `group_key("concord/rekey-pseudonym", + * prior_community_root, channel_id, new_channel_epoch)`. Used when rotating a single + * Private Channel's key rather than the whole community. + */ + fun channelRekeyAddress( + priorCommunityRoot: ByteArray, + channelId: ByteArray, + newChannelEpoch: Long, + ): GroupKey = groupKey(ConcordLabels.REKEY_PSEUDONYM, priorCommunityRoot, channelId, newChannelEpoch) + + /** + * The epoch-key commitment (CORD-02 §A.5): `sha256("concord/epoch-key-commitment" + * ‖ prev_epoch_be8 ‖ prev_key[32])`. A rekey event carries this as `prevcommit`; + * a receiver recomputes it over the key it currently holds and requires equality + * before adopting the new key, proving the rotation extends its own chain. + */ + fun epochKeyCommitment( + prevEpoch: Long, + prevKey: ByteArray, + ): ByteArray { + val prefix = ConcordLabels.EPOCH_KEY_COMMITMENT.encodeToByteArray() + val preimage = ByteArray(prefix.size + 8 + prevKey.size) + prefix.copyInto(preimage, 0) + writeBe64(preimage, prefix.size, prevEpoch) + prevKey.copyInto(preimage, prefix.size + 8) + return sha256(preimage) + } + // ---- CORD-06 rekey locator ------------------------------------------------ /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt index cf6f57527b..0edb36ec9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -73,4 +73,7 @@ object ConcordLabels { /** community_root-scoped rekey pseudonym for Refoundings (CORD-06). */ const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym" + + /** Epoch-key commitment prefix for a rekey's `prevcommit` (CORD-02 §A.5). Not an HKDF label. */ + const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment" } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index 97dfcbfb8a..acd8c2a007 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -157,7 +157,7 @@ object ConcordStreamEnvelope { } require(rumor.verifyId()) { "Rumor id ${rumor.id} is not its NIP-01 hash" } - return OpenedStreamEvent(rumor, seal.kind, seal.pubKey) + return OpenedStreamEvent(rumor, seal.kind, seal.pubKey, seal) } /** Like [open] but returns null instead of throwing on any validation failure. */ @@ -176,11 +176,15 @@ object ConcordStreamEnvelope { /** * The verified result of opening a stream wrap: the author [rumor], the - * [sealKind] it arrived under (20013/20014), and the true [author] pubkey (equal - * to `rumor.pubKey`, surfaced for convenience). + * [sealKind] it arrived under (20013/20014), the true [author] pubkey (equal to + * `rumor.pubKey`, surfaced for convenience), and the verified inner [seal] event + * itself. The [seal] carries the original author's signature, so a Refounding can + * re-wrap a plaintext control seal under a fresh root without re-signing it + * (CORD-06 §3 compaction). */ class OpenedStreamEvent( val rumor: Event, val sealKind: Int, val author: String, + val seal: Event, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt new file mode 100644 index 0000000000..d5811c0501 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -0,0 +1,151 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordRefoundingTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) // retained + private val bob = NostrSignerInternal(KeyPair()) // retained + private val carol = NostrSignerInternal(KeyPair()) // removed + + private val newRoot = ByteArray(32) { 0x5A } + private val now = 1_700_000_000L + + @Test + fun retainedMembersGetNewRootRemovedDoesNot() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val communityId = community.communityId + val priorRoot = community.communityRoot + val priorControl = community.controlPlane + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = priorRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = priorControl, + recipientsXOnly = listOf(alice.pubKey, bob.pubKey), + createdAt = now, + ) + + assertEquals(community.rootEpoch + 1, build.newEpoch) + assertContentEquals(newRoot, build.newRoot) + + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, build.newEpoch) + + // Alice and Bob find the new root; Carol (no blob) does not. + val aliceRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, priorRoot, community.rootEpoch) + val bobRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, bob, priorRoot, community.rootEpoch) + val carolRoot = ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, carol, priorRoot, community.rootEpoch) + + assertNotNull(aliceRoot) + assertContentEquals(newRoot, aliceRoot.newRoot) + assertEquals(owner.pubKey, aliceRoot.rotator) + assertNotNull(bobRoot) + assertContentEquals(newRoot, bobRoot.newRoot) + assertNull(carolRoot) // removed member receives no blob + } + + @Test + fun compactedControlPlaneFoldsIdenticallyUnderNewRoot() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place") + val communityId = community.communityId + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + + val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + + // Re-open the compacted wraps under the NEW control key and fold: same authority + metadata. + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { + com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition + .fromRumor(it.rumor) + } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + + assertEquals("Test", folded.metadata?.name) + assertTrue(folded.authority.isOwner(owner.pubKey)) + // #general survives compaction (its head channel edition is re-sealed). + assertTrue(folded.channels.isNotEmpty()) + + // The re-sealed editions still verify as owner-signed (signature preserved across re-encryption). + build.controlWraps.forEach { wrap -> + val opened = ConcordStreamEnvelope.openOrNull(wrap, newControl) + assertNotNull(opened) + assertEquals(owner.pubKey, opened.author) + } + } + + @Test + fun wrongPriorRootFailsContinuity() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = community.communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = community.genesisWraps, + priorControlKey = community.controlPlane, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, build.newEpoch) + + // Alice claims a different prior root: prevcommit mismatch ⇒ rotation rejected. + val wrongRoot = ByteArray(32) { 0x11 } + assertNull(ConcordRefounding.findNewRoot(build.rekeyWraps, baseRekeyKey, alice, wrongRoot, community.rootEpoch)) + } +} From fffeb78c2a189e702327f295396cc6d90909975d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 14:55:29 +0000 Subject: [PATCH 071/115] fix(concord): decouple CORD-06 aux planes from the control/channel path Regression: after wiring CORD-06, joined communities showed but their channels were empty. Folding the Guestbook + next-epoch base-rekey planes into the SAME kind-1059 REQ and NIP-42 stream-key AUTH set as the control and channel planes starved the whole subscription on relays that gate a REQ on stream-key AUTH: the control plane stopped folding, so no channels appeared (the community list is a separate kind-13302 fetch, so it still showed). Restore the control + channel subscription and AUTH set to exactly their pre-CORD-06 form: drop auxiliaryPlaneSubs from the shared REQ, and drop the Guestbook/next-rekey keys from streamKeys() (moved to auxStreamKeys() for a future isolated subscription). The receive-side pieces (guestbook fold, rekey buffering/drain) stay in place but are dormant until re-introduced in their own subscription that cannot affect the core chat path. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ConcordChannelFilterAssembler.kt | 6 ++++- .../model/concord/ConcordCommunitySession.kt | 22 +++++++++++++------ .../model/concord/ConcordSessionRegistry.kt | 2 -- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index 1b20c93891..ef6c32edb1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -78,7 +78,11 @@ class ConcordChannelSubAssembler( // kind-1059 filters lives in the shared planner. val subs = ArrayList() subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) - subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries) + // NOTE: the CORD-06 Guestbook + next-rekey planes are deliberately NOT folded into this + // shared control+channel REQ. Naming those extra stream keys here starved the whole + // subscription on relays that gate (or close) a REQ on NIP-42 stream-key AUTH, so control + // stopped folding and channels went empty. They'll return in their own isolated + // subscription; keeping the core chat path byte-for-byte what it was before CORD-06. for (entry in entries) { val state = account.concordSessions diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 22f920c792..e92b73cf9f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -114,18 +114,26 @@ class ConcordCommunitySession( fun pendingBaseRekeyWraps(): List = lock.withLock { baseRekeyWraps.values.toList() } /** - * Every stream key whose kind-1059 wraps this session reads: the Control Plane, the - * Guestbook Plane, one per folded channel, and the next-epoch base-rekey address. - * These are the identities a NIP-42 relay must see the connection authenticate as - * (kind 22242) to serve the wraps — a Concord wrap is authored by the stream key and - * `p`-tagged to a throwaway ephemeral key, so the member is neither author nor - * recipient and the relay refuses unless we AUTH as the stream key itself. + * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus + * one per folded channel. These are the identities a NIP-42 relay must see the + * connection authenticate as (kind 22242) to serve the wraps — a Concord wrap is + * authored by the stream key and `p`-tagged to a throwaway ephemeral key, so the + * member is neither author nor recipient and the relay refuses unless we AUTH as the + * stream key itself. + * + * The Guestbook + next-epoch base-rekey planes ([auxStreamKeys]) are intentionally + * NOT included here: mixing them into the shared control/channel AUTH set starved the + * subscription on relays that gate a REQ on stream-key AUTH (control stopped folding, + * channels went empty). They AUTH on their own isolated subscription instead. */ fun streamKeys(): List = lock.withLock { - listOf(controlPlaneKey, guestbookKey, nextBaseRekeyKey) + channelKeysByAddress.values.map { it.second } + listOf(controlPlaneKey) + channelKeysByAddress.values.map { it.second } } + /** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */ + fun auxStreamKeys(): List = listOf(guestbookKey, nextBaseRekeyKey) + /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 60b62b08e0..b6ff4c4039 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -92,8 +92,6 @@ class ConcordSessionRegistry( val out = HashSet() for (session in sessions.values) { out += session.controlPlaneAddress - out += session.guestbookAddress - out += session.nextBaseRekeyAddress out += session.channelAddresses() } out From 824dee6fb691cb36cefcaaee43ea941182306da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 17:25:42 +0000 Subject: [PATCH 072/115] fix(concord): make the Messages-tab community chip reactive to the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip that names each Concord channel's parent community (and opens it on tap) renders only when channel.communityName is set. That value is populated by refreshConcordChannelIndex -> ConcordChannel.updateFrom on each Control Plane fold, but nothing invalidated the channel's metadata flow afterward, so the row (which observes metadata.stateFlow via observeChannel) never recomposed to show the chip — it appeared only if the row happened to recompose for another reason. updateFrom now returns whether a displayed field actually changed, and the index refresh calls updateChannelInfo() only on a real change, so the community name, icon and chip recompose the moment the fold resolves them, without churning every row on every fold tick. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 5 ++- .../commons/model/concord/ConcordChannel.kt | 39 ++++++++++++++----- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e670738245..e56d1f6f03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -516,7 +516,10 @@ class Account( val relays = relaysByCommunity[communityId] ?: emptySet() for (channelIdHex in state.channels.keys) { val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) - channel.updateFrom(state, relays, myPubKey) + // Invalidate the channel's metadata flow only on a real change so the Messages-row + // name + community chip recompose when the fold first resolves them (they observe + // metadata.stateFlow via observeChannel), without churning every row every tick. + if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo() channel.notes .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } .forEach { channel.removeNote(it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 5854f1fc0c..acb8fe9c77 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -76,21 +76,42 @@ class ConcordChannel( * Refresh this channel's metadata from a freshly-folded community [state] plus * the community's [relays] and this account's [myPubKey]. Cheap and idempotent * — called whenever the Control Plane re-folds. + * + * Returns true when a displayed field (channel name, community name/icon, + * membership) actually changed, so the caller can invalidate the channel's + * metadata flow ([updateChannelInfo]) — and thus recompose the Messages-row + * name + community chip — only on a real change, not on every fold tick. */ fun updateFrom( state: ConcordCommunityState, relays: Set, myPubKey: HexKey, - ) { - state.channels[channelId.channelId]?.definition?.let { - channelName = it.name - isVoice = it.voice - isPrivate = it.private - } - communityName = state.metadata?.name - communityIcon = state.metadata?.icon + ): Boolean { + val def = state.channels[channelId.channelId]?.definition + // Channel fields keep their prior value until the channel edition folds. + val newChannelName = def?.name ?: channelName + val newVoice = def?.voice ?: isVoice + val newPrivate = def?.private ?: isPrivate + val newCommunityName = state.metadata?.name + val newCommunityIcon = state.metadata?.icon + val newMembership = ConcordMembership.of(state.authority, myPubKey) + + val changed = + channelName != newChannelName || + isVoice != newVoice || + isPrivate != newPrivate || + communityName != newCommunityName || + communityIcon != newCommunityIcon || + membership != newMembership + + channelName = newChannelName + isVoice = newVoice + isPrivate = newPrivate + communityName = newCommunityName + communityIcon = newCommunityIcon communityRelays = relays - membership = ConcordMembership.of(state.authority, myPubKey) + membership = newMembership + return changed } /** A Concord channel is reachable on any of its community's relays. */ From 0e485349e3a75943f7f0f3df66a0d34cd7b432ba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:03:37 -0400 Subject: [PATCH 073/115] fix(relay-auth): re-authenticate on an `auth-required` CLOSED (NIP-42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord channels loaded empty because the channel-plane REQ was refused until the relay happened to re-issue an AUTH challenge (~27s later, or never on relays that don't re-challenge). A channel's derived stream key only exists after the community's Control Plane folds, but by then the connection's initial NIP-42 AUTH already ran with just the control key. `RelayAuthenticator` only re-authenticated on a fresh `AUTH` message and ignored `auth-required` CLOSED frames — so the newly-revealed channel keys were never sent. NIP-42 says the client must store the connection's challenge and reuse it "in response to the auth-required CLOSED message". Do that: remember the last challenge per relay, and on an `auth-required:` CLOSED re-run the sign/send pass with it. `saveAuthSubmission` dedups by (pubkey, challenge) so only not-yet-authed identities (the folded-in keys) are sent — a no-op once they all are, so no loop. A burst guard skips re-signing while an AUTH is already in flight (syncFilters re-drives the REQ when it settles), and the re-auth is non-interactive: it re-sends only already-approved identities (ledger-ALLOW accounts + stream keys) and never raises a prompt, so it can't drag a bystander account onto a paid relay. Adds an `interactive` flag to the signing callback and a RelayAuthenticatorReauthOnClosedTest covering reuse, loop-safety, burst-coalescing, and the non-auth-required CLOSED no-op. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../relays/eventsync/EventSyncTest.kt | 2 +- .../authCommand/model/AuthCoordinator.kt | 10 +- .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../relay/client/auth/RelayAuthStatus.kt | 16 + .../relay/client/auth/RelayAuthenticator.kt | 46 ++- .../auth/RelayAuthenticatorConcurrencyTest.kt | 2 +- .../RelayAuthenticatorReauthOnClosedTest.kt | 273 ++++++++++++++++++ .../auth/RelayAuthenticatorTimeoutTest.kt | 4 +- 8 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt index bb4feac936..642062322b 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/eventsync/EventSyncTest.kt @@ -97,7 +97,7 @@ class EventSyncTest { RelayAuthenticator( newClient, appScope, - signWithAllLoggedInUsers = { authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate, _ -> listOf(signer.sign(authTemplate)) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index f4b7cdec01..56683a9075 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -55,7 +55,7 @@ class AuthCoordinator( RelayAuthenticator( client, scope, - signWithAllLoggedInUsers = { relayUrl, authTemplate -> + signWithAllLoggedInUsers = { relayUrl, authTemplate, interactive -> // Concord plane traffic is gated behind NIP-42 as the derived *stream key*, not the // user: a relay serves a plane's kind-1059 wraps only to a connection authenticated // as that stream key. These AUTHs expose no user identity (ephemeral derived keys) @@ -102,9 +102,15 @@ class AuthCoordinator( // derived stream-key AUTH behind that dialog: on a relay that hosts our // Concord planes we DISMISS the user-auth ASK (skip account auth) so the // stream AUTHs return immediately instead of waiting on a prompt. + // + // A non-[interactive] pass is an automatic re-auth off an `auth-required:` + // CLOSED (e.g. a Concord channel-plane REQ refused because the connection + // AUTHed before the control plane folded in its channel stream keys). It + // must never raise a fresh dialog: DISMISS the account ASK and let only the + // already-approved identities (ledger-ALLOW accounts + stream keys) re-send. val choice = askChoice ?: ( - if (streamAuths.isNotEmpty()) { + if (streamAuths.isNotEmpty() || !interactive) { UserAuthChoice.DISMISS } else { promptBus.requestDecision(relayUrl, context.purposes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 887bf0915a..773bc34f1e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -349,7 +349,7 @@ class AccountViewModel( RelayAuthenticator( newClient, customScope, - signWithAllLoggedInUsers = { _, authTemplate -> + signWithAllLoggedInUsers = { _, authTemplate, _ -> if (account.signer.isWriteable()) { try { listOf(account.signer.sign(authTemplate)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index 47ac84c4ce..f13652d067 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -43,6 +43,22 @@ class RelayAuthStatus { @Volatile private var lastAuthSuccessAt: Long? = null + // The most recent challenge the relay sent on this connection. NIP-42: the challenge + // "is valid for the duration of the connection or until another challenge is sent", + // and a client "must have a stored challenge associated with that relay so it can act + // upon that in response to the auth-required CLOSED message". We keep it so a REQ that + // is refused with `auth-required:` AFTER the initial AUTH (e.g. a Concord channel-plane + // REQ mounted once the control plane folds and reveals new stream keys) can be + // re-authenticated with the folded-in keys without waiting for the relay to re-challenge. + @Volatile + private var lastChallenge: String? = null + + fun rememberChallenge(challenge: String) { + lastChallenge = challenge + } + + fun lastChallenge(): String? = lastChallenge + enum class AuthEventReceiptStatus { AUTHENTICATING, AUTHENTICATED, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index e91ed4ac79..a6e3493714 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -24,6 +24,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd @@ -61,8 +63,13 @@ class RelayAuthenticator( * Signs the auth template for every currently-logged-in account and returns the signed events. * The [relay] parameter allows callers to check per-relay auth policy before signing. * Returns an empty list to skip authentication for this relay. + * + * [interactive] is true for a fresh relay AUTH challenge (the signer MAY surface a user + * prompt for an undecided relay) and false for an automatic re-auth triggered by an + * `auth-required:` CLOSED (the signer must NOT prompt — it may only re-attach identities + * that are already approved, such as ledger-ALLOW accounts and derived stream keys). */ - val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate) -> List, + val signWithAllLoggedInUsers: suspend (relay: NormalizedRelayUrl, EventTemplate, interactive: Boolean) -> List, ) : IAuthStatus { // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so // this state is mutated concurrently — LargeCache wraps a platform-tuned @@ -101,8 +108,9 @@ class RelayAuthenticator( msg: Message, ) { when (msg) { - is AuthMessage -> authenticate(relay, msg) + is AuthMessage -> authenticate(relay, msg.challenge, interactive = true) is OkMessage -> checkAuthResults(relay, msg) + is ClosedMessage -> reauthenticateIfAuthRequired(relay, msg) } } @@ -119,8 +127,11 @@ class RelayAuthenticator( private fun authenticate( relay: IRelayClient, - msg: AuthMessage, + challenge: String, + interactive: Boolean, ) { + // Store the challenge so a later `auth-required:` CLOSED can reuse it (NIP-42). + authStatus.get(relay.url)?.rememberChallenge(challenge) scope.launch { // Relay auth is automatic and not user-initiated. Signing can fail in // benign, expected ways — e.g. an external NIP-55 signer prompt that the @@ -129,8 +140,8 @@ class RelayAuthenticator( // a CoroutineExceptionHandler (viewModelScope, rememberCoroutineScope, …), // so an uncaught throwable here crashes the whole app. Swallow + log them. try { - val ev = RelayAuthEvent.build(relay.url, msg.challenge) - signWithAllLoggedInUsers(relay.url, ev).forEach { authEvent -> + val ev = RelayAuthEvent.build(relay.url, challenge) + signWithAllLoggedInUsers(relay.url, ev, interactive).forEach { authEvent -> // only send replies to new challenges to avoid infinite loop: if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) @@ -147,6 +158,31 @@ class RelayAuthenticator( } } + /** + * NIP-42: a relay sends the challenge only in an `AUTH` message, never in a `CLOSED`. + * When a REQ is refused with an `auth-required:` CLOSED (e.g. a Concord channel-plane + * REQ mounted after the control plane folded and revealed new stream keys), the relay + * does NOT re-issue a challenge — the client is expected to reuse the one already stored + * for the connection. Re-run the sign/send pass with that challenge: [saveAuthSubmission] + * dedups by (pubkey, challenge), so only identities we haven't AUTHed on this challenge + * yet (the folded-in keys) are actually sent — a no-op once they all are, so no loop. + */ + private fun reauthenticateIfAuthRequired( + relay: IRelayClient, + msg: ClosedMessage, + ) { + if (MachineReadablePrefix.parse(msg.message) != MachineReadablePrefix.AUTH_REQUIRED) return + val status = authStatus.get(relay.url) ?: return + // Coalesce the burst: a relay refuses EVERY currently-open sub with its own `auth-required` + // CLOSED, so a single missing identity yields many CLOSEDs at once. Re-signing on each would + // re-hit an external (NIP-55) signer for every ledger-ALLOW account. Skip while an AUTH is + // still in flight — the OK of the one we already sent runs [checkAuthResults] → syncFilters, + // which re-drives the refused REQ; if it's still refused, that fresh CLOSED re-auths then. + if (!status.hasFinishedAllAuths()) return + val challenge = status.lastChallenge() ?: return + authenticate(relay, challenge, interactive = false) + } + private fun checkAuthResults( relay: IRelayClient, msg: OkMessage, diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt index b375d78031..d7bcc96b86 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorConcurrencyTest.kt @@ -90,7 +90,7 @@ class RelayAuthenticatorConcurrencyTest { val authenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, _ -> emptyList() }, + signWithAllLoggedInUsers = { _, _, _ -> emptyList() }, ) val listener = client.captured diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt new file mode 100644 index 0000000000..e10a5a7c32 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorReauthOnClosedTest.kt @@ -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.quartz.nip01Core.relay.client.auth + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * NIP-42: a relay delivers the challenge only in an `AUTH` message, never in a `CLOSED`. When a REQ + * is refused with an `auth-required:` CLOSED *after* the initial AUTH (e.g. a Concord channel-plane + * REQ mounted once the control plane folds in its channel stream keys), the relay does not + * re-challenge — the client is expected to reuse the stored challenge. These tests pin that: + * - a REQ refused with `auth-required:` re-signs against the stored challenge and sends AUTH for + * the newly-available identities; + * - the dedup makes it loop-safe (a second refusal with no new keys sends nothing); + * - a non-`auth-required` CLOSED never triggers a re-auth; + * - the re-auth is non-interactive (never asks the signing lambda to prompt). + */ +class RelayAuthenticatorReauthOnClosedTest { + private class CapturingClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + @Volatile var captured: RelayConnectionListener? = null + + override fun addConnectionListener(listener: RelayConnectionListener) { + captured = listener + } + } + + private class FakeRelayClient( + override val url: NormalizedRelayUrl, + ) : IRelayClient { + val sent = mutableListOf() + + override fun connect() = Unit + + override fun needsToReconnect() = false + + override fun connectAndSyncFiltersIfDisconnected(ignoreRetryDelays: Boolean) = Unit + + override fun isConnected() = true + + override fun sendOrConnectAndSync(cmd: Command) { + sent.add(cmd) + } + + override fun sendIfConnected(cmd: Command) { + sent.add(cmd) + } + + override fun disconnect() = Unit + } + + private fun authedPubKeys(relay: FakeRelayClient) = relay.sent.filterIsInstance().map { it.event.pubKey } + + /** Acks the newest AUTH the client sent, as a well-behaved relay would, so the auth isn't left in flight. */ + private fun ackNewestAuth( + listener: RelayConnectionListener, + relay: FakeRelayClient, + ) { + val newest = + relay.sent + .filterIsInstance() + .last() + .event + listener.onIncomingMessage(relay, "", OkMessage.accepted(newest.id)) + } + + @Test + fun authRequiredClosedReauthsNewlyAvailableKeyReusingStoredChallenge() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + + val control = NostrSignerInternal(KeyPair()) + val channel = NostrSignerInternal(KeyPair()) + + // Starts with only the control identity; the channel identity becomes available later + // (mirrors a Concord control-plane fold revealing a channel stream key). + val available = mutableListOf(control) + val interactiveFlags = mutableListOf() + + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, interactive -> + interactiveFlags.add(interactive) + available.map { it.sign(template) } + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + // A well-behaved relay acks the control AUTH, so nothing is left in flight. + ackNewestAuth(listener, relay) + + assertEquals(listOf(control.pubKey), authedPubKeys(relay), "Initial AUTH signs only the control key") + assertEquals(listOf(true), interactiveFlags, "The fresh AUTH challenge is interactive") + + // Control plane folds → the channel stream key is now available. + available.add(channel) + + // The channel-plane REQ is refused because the connection isn't AUTHed as the channel key. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("channel-sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")), + ) + + assertEquals( + listOf(control.pubKey, channel.pubKey), + authedPubKeys(relay), + "The auth-required CLOSED re-auths, sending AUTH for the newly-available channel key (control deduped)", + ) + assertEquals(false, interactiveFlags.last(), "A re-auth off a CLOSED is non-interactive") + ackNewestAuth(listener, relay) + + // Loop-safety: a second refusal with no new keys must send nothing more. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("channel-sub", MachineReadablePrefix.AUTH_REQUIRED.format("still authenticating")), + ) + assertEquals( + listOf(control.pubKey, channel.pubKey), + authedPubKeys(relay), + "A repeated auth-required CLOSED with no new identity is a no-op (dedup by pubkey+challenge)", + ) + } + + @Test + fun burstOfAuthRequiredClosedsWhileAnAuthIsInFlightIsCoalesced() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val control = NostrSignerInternal(KeyPair()) + val channel = NostrSignerInternal(KeyPair()) + val available = mutableListOf(control) + + var signCalls = 0 + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> + signCalls++ + available.map { it.sign(template) } + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + // Control AUTH is deliberately NOT acked → it stays in flight. + available.add(channel) + val callsBeforeBurst = signCalls + + // A relay refuses every open sub at once. While the control AUTH is unresolved, these must + // NOT each re-sign (which would re-hit an external signer per ledger-ALLOW account). + repeat(5) { + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub-$it", MachineReadablePrefix.AUTH_REQUIRED.format("auth first")), + ) + } + assertEquals(callsBeforeBurst, signCalls, "No re-sign while an AUTH is still in flight") + + // Once the in-flight AUTH resolves, the next refusal re-auths the newly-available key. + ackNewestAuth(listener, relay) + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub-x", MachineReadablePrefix.AUTH_REQUIRED.format("auth first")), + ) + assertTrue(authedPubKeys(relay).contains(channel.pubKey), "Channel key is authed once the burst settles") + } + + @Test + fun nonAuthRequiredClosedDoesNotReauth() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val signer = NostrSignerInternal(KeyPair()) + + var signCalls = 0 + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> + signCalls++ + listOf(signer.sign(template)) + }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + listener.onIncomingMessage(relay, "", AuthMessage("chal-1")) + val afterInitial = signCalls + + listener.onIncomingMessage(relay, "", ClosedMessage("sub", MachineReadablePrefix.ERROR.format("bad req"))) + listener.onIncomingMessage(relay, "", ClosedMessage("sub", MachineReadablePrefix.RESTRICTED.format("nope"))) + + assertEquals(afterInitial, signCalls, "A non-auth-required CLOSED must not trigger a re-auth") + } + + @Test + fun authRequiredClosedBeforeAnyChallengeIsIgnored() = + runBlocking { + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob()) + val signer = NostrSignerInternal(KeyPair()) + + val client = CapturingClient() + RelayAuthenticator( + client = client, + scope = scope, + signWithAllLoggedInUsers = { _, template, _ -> listOf(signer.sign(template)) }, + ) + val listener = client.captured ?: error("RelayAuthenticator did not register a listener") + val relay = FakeRelayClient(NormalizedRelayUrl("wss://relay.example/")) + + listener.onConnecting(relay) + // No AUTH challenge received yet → no stored challenge → nothing to reuse. + listener.onIncomingMessage( + relay, + "", + ClosedMessage("sub", MachineReadablePrefix.AUTH_REQUIRED.format("authenticate first")), + ) + + assertTrue(relay.sent.isEmpty(), "Without a stored challenge there is nothing to re-auth with") + assertFalse(relay.sent.any { it is AuthCmd }) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt index bbd7ad35cf..39926c8406 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticatorTimeoutTest.kt @@ -102,7 +102,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { _, _ -> + signWithAllLoggedInUsers = { _, _, _ -> throw SignerExceptions.TimedOutException("User didn't accept or reject in time.") }, ) @@ -130,7 +130,7 @@ class RelayAuthenticatorTimeoutTest { RelayAuthenticator( client = client, scope = scope, - signWithAllLoggedInUsers = { _, _ -> + signWithAllLoggedInUsers = { _, _, _ -> listOf(RelayAuthEvent.create(relay.url, "challenge-123", signer)) }, ) From 691adb361cc0bd9c2aaa545eaff91ee2ec64acf0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:03:55 -0400 Subject: [PATCH 074/115] fix(concord): drop deleted-message ghost rows in channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord chat row rendered a permanent "Event is loading or can't be found in your relay list" placeholder when a message's kind-5 delete was processed before the message itself — easy to hit because a reproject re-emits the whole wrap buffer and wrap ordering isn't guaranteed. `consumeConcordRumor` attaches the row before `justConsume`, but `justConsume` bails without loading the event once the rumor has been deleted, leaving an event-null note pinned in the channel forever. Skip attaching a row for a rumor already known deleted (also avoids add/remove churn on every reproject), and after consuming, drop the row if its event never loaded. The reverse order (delete after the message) is still handled by the normal deletion cascade unlinking the note. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/model/LocalCache.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 517a3ab972..f3fff5ce5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -750,9 +750,20 @@ object LocalCache : ILocalCache, ICacheProvider { // Attach to the channel BEFORE justConsume sets the event and notifies feeds, // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). - if (rumor is ChatEvent || rumor is CommentEvent) { - getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)).addNote(getOrCreateNote(rumor.id)) - } + val messageRow = + if (rumor is ChatEvent || rumor is CommentEvent) { + val ch = getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) + val note = getOrCreateNote(rumor.id) + // Skip attaching a row for a message we already know is deleted (its kind-5 delete + // was processed first). Otherwise every reproject — which re-emits the whole wrap + // buffer — would re-add then re-remove it, churning the feed. justConsume still + // records the (already-known) deletion below; a delete arriving LATER is handled by + // the normal deletion cascade unlinking the note from its gatherers. + if (!deletionIndex.hasBeenDeleted(rumor)) ch.addNote(note) + ch to note + } else { + null + } // wasVerified = true: a Concord rumor is unsigned (its `sig` is empty), so a signature // check would fail and the event would never load onto its Note — leaving the chat row // stuck on the "loading / not found" placeholder. Its authenticity is already established @@ -760,6 +771,16 @@ object LocalCache : ILocalCache, ICacheProvider { // binds rumor.pubKey == seal.pubKey, and checks rumor.verifyId()), exactly like a NIP-59 // gift-wrapped DM rumor, so we consume it as pre-verified. justConsume(rumor, null, true) + + // justConsume bails without loading the event when the rumor has already been deleted + // (a kind-5 delete referencing it was processed first — easy to hit in Concord because a + // reproject re-emits the whole wrap buffer and ordering isn't guaranteed) or fails to + // verify. We attached the row up front, so an unpopulated note would otherwise linger as a + // permanent "Event is loading…" ghost. Drop it; the reverse order (delete after the message) + // is already handled by the normal deletion cascade unlinking the note from its gatherers. + messageRow?.let { (ch, note) -> + if (note.event == null) ch.removeNote(note) + } } fun checkGetOrCreatePublicChatChannel(key: String): PublicChatChannel? { From 2b4148ef194ac645e926368dc97a7c2c72f49706 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:04:21 -0400 Subject: [PATCH 075/115] feat(concord): scroll-back history pagination for channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord channels only showed the recent tail the relay served for the channel plane and never loaded older messages on scroll. Add backward `until`+`limit` paging, per relay, on demand — the same model as the NIP-04 per-conversation history. Reuses the shared paging stack as-is (BackwardRelayPager, RelayLoadingCursors, the RelayReach* markers/sentinels and DmHistoryLoadingCard); only the Concord-specific data layer is new: - ConcordChannel holds a per-channel RelayLoadingCursors (`history`), so cursors share the channel's cache lifetime. - ConcordChannelHistory{FilterAssembler,SubAssembler} binds a pager to the open channel, builds `{kinds:[1059], authors:[planePk], until, limit}` per armed relay, and forwards relay callbacks; registered in RelaySubscriptionsCoordinator and mounted by the channel screen. - ConcordCommunitySession.channelPlaneAddress() resolves a channel's REQ author from the fold. - ConcordChannelScreen wires the olderBoundary/markersInGap/sentinels feed hooks and bootstraps an empty channel. The history floor is `now` (not the DM 7-day tail): the Concord live sub isn't a strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging must walk the whole history from the top to reach recent-but-capped messages. Overlap with the live tail is harmless — wraps dedup by id on ingest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../RelaySubscriptionsCoordinator.kt | 6 + .../concord/ConcordChannelScreen.kt | 103 ++++++++++ .../ConcordChannelHistoryFilterAssembler.kt | 191 ++++++++++++++++++ .../ConcordChannelHistorySubscription.kt | 54 +++++ .../commons/model/concord/ConcordChannel.kt | 9 + .../model/concord/ConcordCommunitySession.kt | 3 + 6 files changed, 366 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 027fedeba5..741c99f1eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.profile.datasource.P import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler @@ -134,6 +135,10 @@ class RelaySubscriptionsCoordinator( // control + channel planes live (kind-1059 by derived stream address). val concordChannels = ConcordChannelFilterAssembler(client) + // On-demand backward history pager for whichever Concord Channel screen is open (older wraps by + // until+limit, per relay), the Concord analog of the per-conversation NIP-04 history. + val concordChannelHistory = ConcordChannelHistoryFilterAssembler(client) + val chatroom = ChatroomFilterAssembler(client) val community = CommunityFilterAssembler(client) val gitRepository = RepositoryFilterAssembler(client) @@ -201,6 +206,7 @@ class RelaySubscriptionsCoordinator( relayGroupWarmup, relayGroupsDiscovery, concordChannels, + concordChannelHistory, account, accountForeground, home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index cc0744eea2..9cdd239308 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -43,8 +44,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachCursor +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels +import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -54,6 +63,9 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel @@ -68,7 +80,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -88,6 +106,7 @@ fun ConcordChannelScreen( nav: INav, ) { ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel) val account = accountViewModel.account val channel = remember(account, communityId, channelId) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelId)) } @@ -99,6 +118,24 @@ fun ConcordChannelScreen( ) WatchLifecycleAndUpdateModel(feedViewModel) + // Backward history pager for this open channel (older wraps by until+limit, per relay), mirroring + // the NIP-04 per-conversation history: markers drive paging while on screen, a status card sits at + // the oldest end, and an empty channel bootstraps one page so there is something to scroll from. + val history = remember(accountViewModel) { accountViewModel.dataSources().concordChannelHistory.history } + val loadingHistory by history.loadingMore.collectAsStateWithLifecycle() + val historyStatus by history.status.collectAsStateWithLifecycle() + val limits = + remember(historyStatus) { + buildList { + if (!historyStatus.exhausted) { + historyStatus.relayProgress.forEach { (relay, p) -> + add(RelayReachCursor("cord:${relay.url}", relayShortName(relay), p.reachedUntil, reachState(p), "Concord") { history.advance(relay) }) + } + } + } + } + ConcordBootstrapHistoryWhenEmpty(feedViewModel.feedState, history) + val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) @@ -131,6 +168,37 @@ fun ConcordChannelScreen( routeForLastRead = "Concord/$communityId/$channelId", onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, + // A status card at the oldest end: shows what it's reaching for while it pages and + // crossfades to "All caught up" when every relay runs dry. + olderBoundary = { + DmHistoryLoadingCard( + "Concord", + "Concord", + loadingHistory, + historyStatus.exhausted, + historyStatus.relayCount, + historyStatus.stalledCount, + historyStatus.reachedBack, + historyStatus.relayProgress, + ::formatHistoryReachDate, + ) + }, + // Each relay's window-limit marker at its reached cursor (pure UI). Hidden when exhausted. + markersInGap = + if (limits.isEmpty()) { + null + } else { + { newer, older -> RelayReachMarkers(limits, newer, older) {} } + }, + // Pulls each relay's next page while its marker is on screen, off viewport visibility. + sentinels = + if (limits.isEmpty()) { + null + } else { + { items, listState -> + RelayReachSentinels(limits, listState) { index -> items.getOrNull(index)?.event?.createdAt } + } + }, ) } @@ -147,6 +215,41 @@ fun ConcordChannelScreen( } } +/** + * When the channel opens empty, kick a single history page so there's something to scroll from — from + * there paging is purely demand-driven by the markers' visibility. Debounced so the transient empty + * feed that navigation flashes through doesn't trigger a hunt. Mirrors the DM `BootstrapHistoryWhenEmpty`. + */ +@Composable +private fun ConcordBootstrapHistoryWhenEmpty( + feedContentState: FeedContentState, + history: ConcordChannelHistorySubAssembler, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + val needsBootstrap = feedState is FeedState.Empty + LaunchedEffect(needsBootstrap, history) { + if (!needsBootstrap) return@LaunchedEffect + delay(1200L) + combine(history.loadingMore, history.status) { loading, s -> !loading && !s.exhausted } + .distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } +} + +private fun reachState(p: RelayPagingProgress): RelayReachState = + when { + p.done -> RelayReachState.DONE + p.stalled -> RelayReachState.STALLED + else -> RelayReachState.REACHING + } + +private fun relayShortName(relay: NormalizedRelayUrl): String = + relay.url + .removePrefix("wss://") + .removePrefix("ws://") + .removeSuffix("/") + @Composable private fun ConcordMessageComposer( newMessageModel: ConcordNewMessageViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt new file mode 100644 index 0000000000..a8f32ad938 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -0,0 +1,191 @@ +/* + * 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.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager +import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.StateFlow + +/** One open Concord Channel whose older history the screen wants paged in. */ +class ConcordChannelHistoryQueryState( + val account: Account, + val communityId: String, + val channelId: String, +) + +/** + * Mounts the on-demand **history** pager for whichever Concord Channel screen is open. The live + * [ConcordChannelFilterAssembler] only holds the recent tail the relay serves for each channel plane; + * this pages older messages backward by `until`+`limit` per relay, exactly like the NIP-04 per- + * conversation history ([com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomNip04HistorySubAssembler]). + */ +class ConcordChannelHistoryFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val history = ConcordChannelHistorySubAssembler(client, ::allKeys) + + val group = listOf(history) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +/** + * Pages one Concord Channel's older wraps by `until`+`limit`, per relay, on demand. The per-relay + * cursors live on the channel's [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel] (so + * reopening keeps progress); this binds the single-active [BackwardRelayPager] to the open channel on + * [newSub], builds the kind-1059 channel-plane REQ per armed relay, and forwards relay callbacks into + * the pager. Decryption + landing happen on the normal ingest path (the wraps flow through + * `concordSessions.ingest`); the pager only needs each wrap's `createdAt`. + */ +class ConcordChannelHistorySubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + // Floor at `now` (liveTailSeconds = 0), NOT the DM 7-day tail: the Concord live subscription isn't a + // strict recent-tail (it asks the plane author unbounded and the relay caps the result), so paging + // must walk the WHOLE history from the top to reach "recent but capped" messages. Overlap with the + // live tail is harmless — wraps dedup by id on ingest. + private val pager = BackwardRelayPager("concord.channel.history", liveTailSeconds = 0) + + val loadingMore: StateFlow = pager.loadingMore + val status: StateFlow = pager.status + + override fun id(key: ConcordChannelHistoryQueryState) = ConcordChannelId(key.communityId, key.channelId) + + // This channel's persistent paging cursors, held on its LocalCache ConcordChannel. + private fun cursorsFor(key: ConcordChannelHistoryQueryState) = LocalCache.getOrCreateConcordChannel(id(key)).history + + /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ + private fun relaysFor(key: ConcordChannelHistoryQueryState): Set = + key.account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == key.communityId } + ?.relays + ?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + ?: emptySet() + + /** The channel's derived Chat Plane pubkey — the REQ author. Null until the Control Plane folds it. */ + private fun planePkFor(key: ConcordChannelHistoryQueryState): String? = + key.account.concordSessions + .sessionFor(key.communityId) + ?.channelPlaneAddress(key.channelId) + + override fun updateFilter( + key: ConcordChannelHistoryQueryState, + since: SincePerRelayMap?, + ): List? { + val planePk = planePkFor(key) ?: return emptyList() + val relays = relaysFor(key) + // Only armed (advanced, not done) relays carry a REQ, each at its own requested cursor. A parked + // relay keeps the same filter here, so re-assembly (another relay advancing) doesn't re-REQ it. + val armed = pager.armedRelays(relays) + if (armed.isEmpty()) return emptyList() + return armed.mapNotNull { relay -> + val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = listOf(planePk), + until = until, + limit = pager.pageLimit, + ), + ) + } + } + + /** Steps a single [relay] to its next, older page for the open channel. Driven by its on-screen marker. */ + fun advance(relay: NormalizedRelayUrl) { + if (pager.advance(relay)) invalidateFilters() + } + + /** Steps every not-done, not-in-flight relay one page. For a channel too short to scroll. */ + fun advanceAll() { + if (pager.advanceAll()) invalidateFilters() + } + + override fun newSub(key: ConcordChannelHistoryQueryState): Subscription { + // Repoint the single-active orchestrator at this channel's cursors and its community relays. + pager.bind(cursorsFor(key), key.account.scope) { relaysFor(key) } + return requestNewSubscription(historyListener(key)) + } + + private fun historyListener(key: ConcordChannelHistoryQueryState): SubscriptionListener { + // A just-backgrounded channel's subscription can still deliver after the orchestrator rebinds to + // another channel; gate the pager (single-active) on whether it's still bound to THIS channel's + // cursors so a late callback can't move another channel's cursors. newEose runs regardless. + val myCursors = cursorsFor(key) + return object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onEose(relay) + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt new file mode 100644 index 0000000000..409d157e4b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistorySubscription.kt @@ -0,0 +1,54 @@ +/* + * 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.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +/** + * Mount on the open Concord Channel screen to keep its backward-history pager bound and armed. The + * channel's Chat Plane pubkey (the REQ author) is only known once the Control Plane folds, so — like + * [ConcordChannelSubscription] — we re-derive the history filter whenever + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] advances. + */ +@Composable +fun ConcordChannelHistorySubscription( + communityId: String, + channelId: String, + dataSource: ConcordChannelHistoryFilterAssembler, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val state = remember(account, communityId, channelId) { ConcordChannelHistoryQueryState(account, communityId, channelId) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + // A fold can reveal this channel's plane pubkey (the REQ author) for the first time. + dataSource.invalidateFilters() + } + + LifecycleAwareKeyDataSourceSubscription(state, dataSource) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 5854f1fc0c..2d4f36c597 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl /** @@ -72,6 +73,14 @@ class ConcordChannel( var membership: ConcordMembership = ConcordMembership.MEMBER private set + /** + * Per-relay backward-pagination cursors for this channel's history (CORD-03). The live + * subscription only holds the recent tail the relay serves for the channel plane; older + * messages are paged in on demand by `until`+`limit` as the user scrolls, exactly like the + * NIP-04 per-conversation history. Held here so the cursors share the channel's cache lifetime. + */ + val history = RelayLoadingCursors() + /** * Refresh this channel's metadata from a freshly-folded community [state] plus * the community's [relays] and this account's [myPubKey]. Cheap and idempotent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index e92b73cf9f..a3b2574ed3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -107,6 +107,9 @@ class ConcordCommunitySession( /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } + /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } + /** The base-rotation rekey [GroupKey] a member opens an inbound Refounding under. */ fun nextBaseRekeyKey(): GroupKey = nextBaseRekeyKey From 95ab9e6528a4a64219bae1bfd41749ce5a1fc076 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:14:30 -0400 Subject: [PATCH 076/115] fix(desktop,cli): match the new interactive auth-callback signature The relay-auth fix added an `interactive` flag to RelayAuthenticator.signWithAllLoggedInUsers; update the desktop and cli implementers (which don't prompt) to the 3-arg lambda. Co-Authored-By: Claude Opus 4.8 (1M context) --- cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt | 2 +- .../amethyst/desktop/auth/DesktopAuthCoordinator.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index b478ef8b85..82cc60df9a 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -258,7 +258,7 @@ class Context( private val relayAuth: RelayAuthenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, template -> + signWithAllLoggedInUsers = { _, template, _ -> if (signer is NostrSignerInternal) { runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } } else { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt index 0f6de969a9..fa06c42d16 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -105,7 +105,7 @@ class DesktopAuthCoordinator( RelayAuthenticator( client = relayManager.client, scope = scope, - signWithAllLoggedInUsers = { relayUrl, template -> + signWithAllLoggedInUsers = { relayUrl, template, _ -> val signed = signWithPolicy(account, relayUrl, template, policy) signed?.let { listOf(it) } ?: emptyList() }, From 975ccb9cf33e075d916056587ef83853d800aa15 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:40:45 -0400 Subject: [PATCH 077/115] feat(concord): preload community control planes app-wide, like DMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Communities only folded — revealing their channels, metadata (name/icon) and membership — while a Concord screen was actually open, because the Control-Plane subscription (ConcordChannelSubscription) was mounted only on the six Concord screens. Sit on the Home feed and nothing streams in; a community you haven't opened stays unfolded (no channels, no image). Concord control-plane wraps are addressed to derived stream keys, not `#p=self`, so the always-on account/DM gift-wrap tail can't pick them up. Add ConcordChannelPreload — an account-level, always-on mount using the non-lifecycle KeyDataSourceSubscription (the same primitive AccountFilterAssemblerSubscription uses for DMs) with the existing revision-driven filter re-derivation — and mount it in LoggedInPage next to the DM/account preload. Now every joined community's planes are requested from login regardless of screen, so folds happen in the background. The already-always-on ingest/refreshConcordChannelIndex path was only missing this continuous network request. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/screen/loggedIn/LoggedInPage.kt | 6 ++++ .../datasource/ConcordChannelSubscription.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 49704a9575..c50f6bbcd3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.Account import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelPreload import com.vitorpamplona.quartz.nip55AndroidSigner.client.IActivityLauncher import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log @@ -89,6 +90,11 @@ fun LoggedInPage( // Loads account information + DMs and Notifications from Relays. AccountFilterAssemblerSubscription(accountViewModel) + // Preloads every joined Concord community's Control planes app-wide (their wraps are addressed to + // derived stream keys, so the always-on DM tail can't pick them up) — so communities fold, and + // their channels/metadata/icon appear, without waiting for a Concord screen to be opened. + ConcordChannelPreload(accountViewModel) + // Foreground-only loaders: follows-outbox finder + random-relay notifications. // Pauses on ON_STOP, resumes on ON_START. AccountForegroundFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt index 034c31d7ed..04657724b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelSubscription.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -62,3 +63,30 @@ fun ConcordChannelSubscription( LifecycleAwareKeyDataSourceSubscription(state, dataSource) } + +/** + * Always-on account-level preload of every joined community's Control (and folded Chat) planes, + * mounted once high in the logged-in tree ([com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage]) + * — the Concord analog of the always-on account/DM gift-wrap tail + * ([com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription]). + * + * Concord control-plane wraps are addressed to *derived stream keys*, not `#p=self`, so the always-on + * DM tail never picks them up — without this, communities only fold (and thus reveal their channels, + * metadata/icon and membership) while a Concord screen happens to be open. Uses the non-lifecycle + * [KeyDataSourceSubscription] so the planes stay requested app-wide, exactly like DMs, and keeps the + * same [com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager.revision] watch so a + * fresh fold subscribes its newly-revealed channel planes. + */ +@Composable +fun ConcordChannelPreload(accountViewModel: AccountViewModel) { + val account = accountViewModel.account + val dataSource = accountViewModel.dataSources().concordChannels + val state = remember(account) { ConcordChannelQueryState(account) } + + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + LaunchedEffect(revision) { + dataSource.invalidateFilters() + } + + KeyDataSourceSubscription(state, dataSource) +} From 28b93132a3ebf2d12f9cd46871f1b63aa9c1b7e9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:16:12 -0400 Subject: [PATCH 078/115] =?UTF-8?q?feat(concord):=20read=20+=20render=20CO?= =?UTF-8?q?RD-02=20=C2=A76=20encrypted=20community=20icon/banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord community icons never showed (robohash instead), and the community name silently fell back to the invite name. Root cause: the icon/banner are CORD-02 §6 **encrypted media** — the metadata entity carries an `ImagePointer` object `{url,key,nonce,hash}` (AES-256-GCM ciphertext at `url`, decrypted with `key`/`nonce`, `hash` = SHA-256 of the plaintext) — but `MetadataEntity.icon` was typed `String?`. An object where a String is expected fails the whole entity's decode, so metadata came back null: no icon, and the name dropped to the entry fallback. Matches the Concord v2 reference client (Armada `concord-v2/lib/{types,image}.ts`). - Promote `ImagePointer` to a shared CORD-02 type (was invite-only) and give it `decryptOrNull` (AES-256-GCM via the existing `AESGCM`, verifying the plaintext SHA-256 — a swapped blob fails closed). - `MetadataEntity.icon`/`banner` are now `ImagePointer?`, so the entity (and the community name) decodes. `ConcordChannel` carries the pointers. - `rememberConcordImageModel` resolves a pointer for the avatar: a plain-URL pointer (Amethyst's own form) passes through; an encrypted one is fetched, decrypted, verified, cached to disk, and rendered — else the robohash. Wired into the Concord hub avatars and the Messages-tab community chip. - Amethyst's create/edit still take a URL and wrap it as a url-only pointer; authoring encrypted images (encrypt + upload) is a follow-up. Adds ImagePointerTest: Armada-shape object decode, decrypt round-trip, and fail-closed on a tampered hash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 5 +- .../concord/ConcordCommunityImage.kt | 89 ++++++++++++++ .../concord/ConcordCreateScreen.kt | 7 +- .../concord/ConcordEditScreen.kt | 9 +- .../concord/ConcordHomeScreen.kt | 13 ++- .../chats/rooms/ChatroomHeaderCompose.kt | 5 +- .../commons/actions/ConcordActions.kt | 3 +- .../commons/model/concord/ConcordChannel.kt | 12 +- .../ConcordCommunityFactory.kt | 2 +- .../concord/cord02Community/ImagePointer.kt | 61 ++++++++++ .../concord/cord04Roles/ControlEntities.kt | 13 ++- .../concord/cord05Invites/CommunityInvite.kt | 10 +- .../cord02Community/ImagePointerTest.kt | 110 ++++++++++++++++++ 13 files changed, 311 insertions(+), 28 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e56d1f6f03..2ad44d3178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -145,6 +145,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity @@ -1935,7 +1936,7 @@ class Account( name: String, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): String? { if (!isWriteable()) return null val relayUrls = relays.ifEmpty { outboxRelays.flow.value.map { it.url } } @@ -2443,7 +2444,7 @@ class Account( communityId: String, name: String, description: String?, - icon: String?, + icon: ImagePointer?, relays: List, ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt new file mode 100644 index 0000000000..c6f38c3dbf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityImage.kt @@ -0,0 +1,89 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Request +import java.io.File +import java.util.concurrent.ConcurrentHashMap + +// Decrypt-once memo per plaintext hash → the on-disk file:// model. Object URLs never change for a +// given hash (content-addressed), so this is safe to keep for the process lifetime and bounded by the +// number of distinct community images a session touches. +private val resolvedByHash = ConcurrentHashMap() + +/** + * Resolve a CORD-02 §6 community [pointer] to a model string for [RobohashFallbackAsyncImage]: + * + * - **null / blank** → null (caller falls back to the robohash). + * - **plain URL** (a url-only pointer, e.g. Amethyst's own metadata form) → the URL, loaded directly. + * - **encrypted** (`key`/`nonce`/`hash` present) → fetch the ciphertext, AES-256-GCM-decrypt + verify + * the plaintext SHA-256 (all in [ImagePointer.decryptOrNull]), cache the plaintext to disk, and + * return its `file://` path. Returns null while loading or on any fetch/decrypt/integrity failure, + * so a swapped or unreachable blob simply shows the robohash instead of garbage. + */ +@Composable +fun rememberConcordImageModel( + pointer: ImagePointer?, + accountViewModel: AccountViewModel, +): String? { + if (pointer == null) return null + + // A url-only pointer isn't encrypted media — hand the URL straight to Coil. + if (!pointer.isResolvable()) return pointer.url.ifBlank { null } + + val context = LocalContext.current + val model by produceState(resolvedByHash[pointer.hash], pointer, accountViewModel) { + if (value != null) return@produceState + value = + withContext(Dispatchers.IO) { + runCatching { + resolvedByHash[pointer.hash]?.let { return@runCatching it } + + val cacheFile = File(context.cacheDir, "concord-img-${pointer.hash}") + if (!cacheFile.exists()) { + val client = accountViewModel.httpClientBuilder.okHttpClientForImage(pointer.url) + val ciphertext = + client.newCall(Request.Builder().url(pointer.url).build()).execute().use { resp -> + if (!resp.isSuccessful) return@runCatching null + resp.body?.bytes() + } ?: return@runCatching null + + val plaintext = pointer.decryptOrNull(ciphertext) ?: return@runCatching null + cacheFile.writeBytes(plaintext) + } + val uri = "file://${cacheFile.absolutePath}" + resolvedByHash[pointer.hash] = uri + uri + }.onFailure { Log.w("ConcordImage", "Failed to resolve community image ${pointer.url}", it) } + .getOrNull() + } + } + return model +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index d8c680f857..62d5c62f4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch @@ -133,7 +134,11 @@ fun ConcordCreateScreen( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, - icon = iconUrl.value.trim().ifBlank { null }, + icon = + iconUrl.value + .trim() + .ifBlank { null } + ?.let { ImagePointer(url = it) }, ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 888b858648..9a89e1d552 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -93,7 +94,7 @@ fun ConcordEditScreen( if (!prefilled && md != null) { name.value = md.name about.value = md.description.orEmpty() - iconUrl.value = md.icon.orEmpty() + iconUrl.value = md.icon?.url.orEmpty() prefilled = true } } @@ -143,7 +144,11 @@ fun ConcordEditScreen( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, - icon = iconUrl.value.trim().ifBlank { null }, + icon = + iconUrl.value + .trim() + .ifBlank { null } + ?.let { ImagePointer(url = it) }, relays = state?.metadata?.relays ?: session.entry.relays, ) working = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 058e0b9148..817cb48416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -164,7 +165,7 @@ fun ConcordHomeScreen( CommunityHeader( communityId = entry.id, name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, - iconUrl = state?.metadata?.icon, + iconPointer = state?.metadata?.icon, channelCount = state?.channels?.size ?: 0, expanded = isOpen, accountViewModel = accountViewModel, @@ -213,7 +214,7 @@ private fun CommunityRail( contentPadding = PaddingValues(horizontal = 16.dp), ) { items(communities, key = { it.id }) { entry -> - val iconUrl = + val iconPointer = accountViewModel.account.concordSessions .sessionFor(entry.id) ?.state @@ -221,11 +222,12 @@ private fun CommunityRail( ?.metadata ?.icon .takeIf { revision >= 0 } + val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) val isOpen = entry.id in expanded val ring = if (isOpen) MaterialTheme.colorScheme.primary else Color.Transparent RobohashFallbackAsyncImage( robot = entry.id, - model = iconUrl, + model = iconModel, contentDescription = entry.name, modifier = Modifier @@ -245,7 +247,7 @@ private fun CommunityRail( private fun CommunityHeader( communityId: String, name: String, - iconUrl: String?, + iconPointer: ImagePointer?, channelCount: Int, expanded: Boolean, accountViewModel: AccountViewModel, @@ -253,6 +255,7 @@ private fun CommunityHeader( onOpen: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, @@ -260,7 +263,7 @@ private fun CommunityHeader( ) { RobohashFallbackAsyncImage( robot = communityId, - model = iconUrl, + model = iconModel, contentDescription = name, modifier = Modifier.size(40.dp).clip(CircleShape).clickable(onClick = onOpen), loadProfilePicture = accountViewModel.settings.showProfilePictures(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 1efa084f19..7d893641e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.RelayGroupServerRoomNote @@ -446,7 +447,7 @@ private fun ConcordRoomCompose( ChannelName( channelIdHex = channel.channelId.channelId, - channelPicture = channel.communityIcon, + channelPicture = rememberConcordImageModel(channel.communityIcon, accountViewModel), channelTitle = { modifier -> Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( @@ -549,7 +550,7 @@ private fun ConcordServerRoomCompose( ChannelName( channelIdHex = row.communityId, - channelPicture = metadata?.icon, + channelPicture = rememberConcordImageModel(metadata?.icon, accountViewModel), channelTitle = { modifier -> ChannelTitleWithLabelInfo(name, R.string.concord_server_label, modifier) }, channelLastTime = row.newestMessage?.createdAt(), channelLastContent = lastContent, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 45e9ac2c90..a9161c62cb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.Guestbook import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys @@ -125,7 +126,7 @@ object ConcordActions { createdAt: Long, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon) /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 6a04ac29fa..67f090e3fa 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors @@ -61,8 +62,12 @@ class ConcordChannel( var communityName: String? = null private set - /** The parent community's icon URL, from its folded metadata (null if unset). */ - var communityIcon: String? = null + /** The parent community's encrypted-media icon pointer, from its folded metadata (null if unset). */ + var communityIcon: ImagePointer? = null + private set + + /** The parent community's encrypted-media banner pointer, from its folded metadata (null if unset). */ + var communityBanner: ImagePointer? = null private set /** The community's bootstrap relays — a channel plane may be mirrored on all of them. */ @@ -103,6 +108,7 @@ class ConcordChannel( val newPrivate = def?.private ?: isPrivate val newCommunityName = state.metadata?.name val newCommunityIcon = state.metadata?.icon + val newCommunityBanner = state.metadata?.banner val newMembership = ConcordMembership.of(state.authority, myPubKey) val changed = @@ -111,6 +117,7 @@ class ConcordChannel( isPrivate != newPrivate || communityName != newCommunityName || communityIcon != newCommunityIcon || + communityBanner != newCommunityBanner || membership != newMembership channelName = newChannelName @@ -118,6 +125,7 @@ class ConcordChannel( isPrivate = newPrivate communityName = newCommunityName communityIcon = newCommunityIcon + communityBanner = newCommunityBanner communityRelays = relays membership = newMembership return changed diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt index 314bc953cd..f7d320c5ff 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -76,7 +76,7 @@ object ConcordCommunityFactory { createdAt: Long, description: String? = null, relays: List = emptyList(), - icon: String? = null, + icon: ImagePointer? = null, ): NewConcordCommunity { val ownerXOnly = ownerSigner.pubKey.hexToByteArray() val ownerSalt = ConcordKeyDerivation.newOwnerSalt() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt new file mode 100644 index 0000000000..8c194f660e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointer.kt @@ -0,0 +1,61 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.Serializable + +/** + * A CORD-02 §6 **encrypted-media** pointer (community/channel icon or banner). The media host stores + * only ciphertext; the per-image AES-256-GCM [key] + [nonce] ride inside the member-sealed Control + * Plane metadata, and [hash] is the SHA-256 of the *plaintext* so a swapped blob fails closed. + * + * Wire shape is pinned to the Concord v2 reference client (`concord-v2/lib/types.ts`): an object, + * not a URL string — a member fetches [url], AES-256-GCM-decrypts with [key]/[nonce], then verifies + * the plaintext SHA-256 equals [hash] before displaying. + */ +@Serializable +data class ImagePointer( + val url: String = "", + /** Hex AES-256-GCM key (32 bytes). */ + val key: String = "", + /** Hex AES-GCM nonce / IV (16 bytes). */ + val nonce: String = "", + /** Hex SHA-256 of the plaintext, for integrity. */ + val hash: String = "", +) { + /** True once every field needed to fetch + decrypt is present. */ + fun isResolvable(): Boolean = url.isNotBlank() && key.isNotBlank() && nonce.isNotBlank() && hash.isNotBlank() + + /** + * Decrypt the fetched [ciphertext] blob (AES-256-GCM under [key]/[nonce], CORD-02 §6) and verify + * the plaintext SHA-256 against [hash]. Returns the plaintext image bytes, or null if decryption or + * the integrity check fails — a swapped or corrupt blob fails closed rather than rendering garbage. + */ + fun decryptOrNull(ciphertext: ByteArray): ByteArray? { + val plaintext = AESGCM(key.hexToByteArray(), nonce.hexToByteArray()).decryptOrNull(ciphertext) ?: return null + if (sha256(plaintext).toHexKey() != hash.lowercase()) return null + return plaintext + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 9673a26516..e7df8bd3a2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer @@ -99,13 +100,19 @@ class ChannelEntity( ) /** - * A community's Metadata content (CORD-02): display [name], optional [icon] and - * [description], and the community's bootstrap [relays]. Client-extensible. + * A community's Metadata content (CORD-02): display [name], optional [description], the community's + * bootstrap [relays], and the encrypted-media [icon]/[banner] pointers. Client-extensible. + * + * [icon]/[banner] are CORD-02 §6 [ImagePointer]s (an object `{url,key,nonce,hash}`), NOT plain URLs — + * the wire shape is pinned to the Concord v2 reference client. Deserializing them into anything else + * (e.g. a `String`) fails the whole entity's decode, which is why a wrong type silently drops the + * community name too. */ @Serializable class MetadataEntity( val name: String = "", - val icon: String? = null, + val icon: ImagePointer? = null, + val banner: ImagePointer? = null, val description: String? = null, val relays: List = emptyList(), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt index 95d94ffc61..2cafcfe5f3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/CommunityInvite.kt @@ -20,18 +20,10 @@ */ package com.vitorpamplona.quartz.concord.cord05Invites +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -/** An encrypted-media image reference (CORD-02): where the bytes are and how to decrypt them. */ -@Serializable -class ImagePointer( - val url: String = "", - val key: String = "", - val nonce: String = "", - val hash: String = "", -) - /** A channel grant carried in an invite: its id, delivered [key], [epoch], and [name]. */ @Serializable class InviteChannel( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt new file mode 100644 index 0000000000..c13c53164c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ImagePointerTest.kt @@ -0,0 +1,110 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ImagePointerTest { + /** + * The community icon/banner are CORD-02 §6 [ImagePointer] *objects* on the wire (Concord v2 + * reference client), not URL strings. Typing them as `String` — the old bug — makes the whole + * MetadataEntity fail to decode, silently dropping the community name too. This pins the object + * shape decoding correctly, name included. + */ + @Test + fun decodesArmadaShapeMetadataWithEncryptedIconObject() { + val json = + """ + { + "name": "NosFabrica", + "description": "a community", + "icon": { "url": "https://media.example/icon.enc", "key": "${"1a".repeat(32)}", "nonce": "${"2b".repeat(16)}", "hash": "${"3c".repeat(32)}" }, + "banner": { "url": "https://media.example/banner.enc", "key": "${"4d".repeat(32)}", "nonce": "${"5e".repeat(16)}", "hash": "${"6f".repeat(32)}" }, + "relays": ["wss://relay.example/"] + } + """.trimIndent() + + val md = ConcordJson.decodeOrNull(json) + assertNotNull(md, "an object-shaped icon must decode, not fail the whole entity") + assertEquals("NosFabrica", md.name) + assertEquals("https://media.example/icon.enc", md.icon?.url) + assertEquals("2b".repeat(16), md.icon?.nonce) + assertEquals("https://media.example/banner.enc", md.banner?.url) + assertTrue(md.icon!!.isResolvable()) + } + + /** A metadata with no images still decodes (both pointers null). */ + @Test + fun decodesMetadataWithoutImages() { + val md = ConcordJson.decodeOrNull("""{"name":"NoPics"}""") + assertNotNull(md) + assertEquals("NoPics", md.name) + assertNull(md.icon) + assertNull(md.banner) + } + + /** decryptOrNull round-trips AES-256-GCM with the pointer's key/nonce and verifies the plaintext hash. */ + @Test + fun decryptRoundTripsAndVerifiesHash() { + val plaintext = "the real PNG bytes".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { (it + 7).toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val pointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = sha256(plaintext).toHexKey(), + ) + + assertEquals(plaintext.toHexKey(), pointer.decryptOrNull(ciphertext)?.toHexKey()) + } + + /** A swapped blob (wrong plaintext hash) fails closed — decryptOrNull returns null, never garbage. */ + @Test + fun tamperedHashFailsClosed() { + val plaintext = "original".encodeToByteArray() + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(16) { it.toByte() } + val ciphertext = AESGCM(key, nonce).encrypt(plaintext) + + val wrongHashPointer = + ImagePointer( + url = "https://media.example/blob", + key = key.toHexKey(), + nonce = nonce.toHexKey(), + hash = "00".repeat(32), // not the plaintext's hash + ) + + assertNull(wrongHashPointer.decryptOrNull(ciphertext)) + } +} From 413bf726fd68a0b62fa16494cd71ce035a58433a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:32:56 -0400 Subject: [PATCH 079/115] feat(concord): author encrypted community icons (encrypt + Blossom upload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CORD-02 §6 image write path: the community-metadata form's icon hero is now a photo picker that AES-256-GCM-encrypts the chosen image under a fresh key/nonce, uploads the *ciphertext* as an opaque blob to the account's Blossom server, and seals the resulting ImagePointer {url,key,nonce,hash} into the metadata — the inverse of the read path, and what Armada does in concord-v2 (`encryptImageBlob` + Blossom upload). - ConcordImageUploader reuses the existing NIP-17 DM encrypted-media primitives (AESGCM + BlossomUploader.upload(inputStream, …) + the account's Blossom server list + createBlossomUploadAuth). The blob is content- addressed by the ciphertext SHA-256; the pointer's hash is the plaintext SHA-256 for read-side integrity. - ConcordMetadataFields now holds an ImagePointer? and its hero opens the photo picker (spinner while uploading), replacing the plain-URL field — a URL-string icon was never CORD-02-valid (Armada renders robohash for it). - Create/edit pass the encrypted pointer straight through to createConcordCommunity / editConcordMetadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordCreateScreen.kt | 10 +- .../concord/ConcordEditScreen.kt | 12 +-- .../concord/ConcordImageUploader.kt | 101 ++++++++++++++++++ .../concord/ConcordMetadataForm.kt | 76 ++++++++----- 4 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 62d5c62f4d..a2077a1097 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -71,7 +71,7 @@ fun ConcordCreateScreen( ) { val name = remember { mutableStateOf("") } val about = remember { mutableStateOf("") } - val iconUrl = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } val relays = remember { mutableListOf().toMutableStateList() } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -100,7 +100,7 @@ fun ConcordCreateScreen( ConcordMetadataFields( name = name, about = about, - iconUrl = iconUrl, + icon = icon, robotSeed = "concord-new", accountViewModel = accountViewModel, ) @@ -134,11 +134,7 @@ fun ConcordCreateScreen( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, - icon = - iconUrl.value - .trim() - .ifBlank { null } - ?.let { ImagePointer(url = it) }, + icon = icon.value, ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 9a89e1d552..285954831a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -83,7 +83,7 @@ fun ConcordEditScreen( val name = remember { mutableStateOf("") } val about = remember { mutableStateOf("") } - val iconUrl = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } var prefilled by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -94,7 +94,7 @@ fun ConcordEditScreen( if (!prefilled && md != null) { name.value = md.name about.value = md.description.orEmpty() - iconUrl.value = md.icon?.url.orEmpty() + icon.value = md.icon prefilled = true } } @@ -129,7 +129,7 @@ fun ConcordEditScreen( ConcordMetadataFields( name = name, about = about, - iconUrl = iconUrl, + icon = icon, robotSeed = communityId, accountViewModel = accountViewModel, ) @@ -144,11 +144,7 @@ fun ConcordEditScreen( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, - icon = - iconUrl.value - .trim() - .ifBlank { null } - ?.let { ImagePointer(url = it) }, + icon = icon.value, relays = state?.metadata?.relays ?: session.entry.relays, ) working = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt new file mode 100644 index 0000000000..5b3c7af3be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -0,0 +1,101 @@ +/* + * 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 android.content.Context +import android.net.Uri +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream + +/** + * Authors a CORD-02 §6 encrypted community image: AES-256-GCM-encrypts the plaintext under a fresh + * random key/nonce (same scheme as NIP-17 DM encrypted media), uploads the *ciphertext* as an opaque + * blob to the account's Blossom server, and returns the [ImagePointer] to seal in the community + * metadata — the exact inverse of [rememberConcordImageModel]'s read path. Mirrors Armada's + * `encryptImageBlob` + Blossom upload in `concord-v2/lib/image.ts`. + */ +class ConcordImageUploader( + private val account: Account, +) { + suspend fun uploadEncrypted( + plaintext: ByteArray, + context: Context, + ): ImagePointer { + val serverBaseUrl = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() + ?: DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom }.baseUrl + + val cipher = AESGCM() + val ciphertext = cipher.encrypt(plaintext) + + val result = + withContext(Dispatchers.IO) { + BlossomUploader().upload( + // The blob is content-addressed by the SHA-256 of the *uploaded* (encrypted) bytes; + // the pointer's own hash below is over the *plaintext* for integrity on read. + inputStream = ByteArrayInputStream(ciphertext), + hash = sha256(ciphertext).toHexKey(), + length = ciphertext.size.toLong(), + baseFileName = "concord-image", + contentType = "application/octet-stream", + alt = "Encrypted Concord community image", + sensitiveContent = null, + serverBaseUrl = serverBaseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + val url = result.url ?: throw IllegalStateException("Blossom upload returned no URL") + return ImagePointer( + url = url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + hash = sha256(plaintext).toHexKey(), + ) + } + + /** Reads the picked [uri]'s bytes then [uploadEncrypted]s them. */ + suspend fun uploadEncrypted( + uri: Uri, + context: Context, + ): ImagePointer { + val bytes = + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } ?: throw IllegalStateException("Could not read the selected image") + return uploadEncrypted(bytes, context) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index fb712c7079..ec6254a156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,18 +32,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +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.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -48,26 +55,26 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import kotlinx.coroutines.launch /** - * The shared metadata form for creating and editing a Concord community — a large - * circular icon preview at the top that reflects the icon URL live (tap it to jump - * to the URL field), then the name, description, and icon-URL fields. Mirrors the - * NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features - * feel consistent. Callers own the state and add the surrounding scaffold, relays - * section (create only), and the create/save action. + * The shared metadata form for creating and editing a Concord community — a large circular icon + * hero at the top (tap to pick an image, which is AES-256-GCM-encrypted and uploaded to Blossom as + * a CORD-02 §6 [ImagePointer], see [ConcordImageUploader]), then the name and description fields. + * Mirrors the NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features feel + * consistent. Callers own the state and add the surrounding scaffold, relays section (create only), + * and the create/save action. */ @Composable fun ConcordMetadataFields( name: MutableState, about: MutableState, - iconUrl: MutableState, + icon: MutableState, robotSeed: String, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val iconFocus = remember { FocusRequester() } - Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -75,10 +82,9 @@ fun ConcordMetadataFields( ) { ConcordIconHero( robotSeed = robotSeed, - iconUrl = iconUrl.value, + icon = icon, displayName = name.value, accountViewModel = accountViewModel, - onClick = { iconFocus.requestFocus() }, ) OutlinedTextField( @@ -96,45 +102,61 @@ fun ConcordMetadataFields( maxLines = 5, label = { Text(stringRes(R.string.concord_create_about)) }, ) - OutlinedTextField( - value = iconUrl.value, - onValueChange = { iconUrl.value = it }, - modifier = Modifier.fillMaxWidth().focusRequester(iconFocus), - singleLine = true, - label = { Text(stringRes(R.string.concord_create_icon)) }, - placeholder = { Text("https://…/icon.png") }, - ) } } -/** The circular community-icon hero: shows the icon URL live over a stable robohash placeholder. */ +/** + * 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 + * [icon] to the resulting encrypted pointer. A spinner covers the hero while the upload is in flight. + */ @Composable private fun ConcordIconHero( robotSeed: String, - iconUrl: String, + icon: MutableState, displayName: String, accountViewModel: AccountViewModel, - onClick: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var uploading by remember { mutableStateOf(false) } + val iconModel = rememberConcordImageModel(icon.value, accountViewModel) + + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + uploading = true + scope.launch { + try { + icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: Exception) { + Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show() + } finally { + uploading = false + } + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier .size(104.dp) .clip(CircleShape) - .clickable(onClick = onClick), + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, contentAlignment = Alignment.Center, ) { RobohashFallbackAsyncImage( robot = robotSeed, - model = iconUrl.ifBlank { null }, + model = iconModel, contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) }, modifier = Modifier.size(104.dp).clip(CircleShape), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = autoPlayGif, ) + if (uploading) CircularProgressIndicator(modifier = Modifier.size(36.dp)) } Text( text = stringRes(R.string.concord_create_icon_hint), @@ -146,7 +168,7 @@ private fun ConcordIconHero( Modifier .padding(top = 8.dp) .clip(CircleShape) - .clickable(onClick = onClick) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } .padding(horizontal = 8.dp, vertical = 4.dp), ) } From 070dd1b1d9eb6927e55f5028b7b1c419527fcf49 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:28:57 +0000 Subject: [PATCH 080/115] feat(concord): move the Concord hub from the FAB to the drawer + make it bottom-nav pinnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Concord Channels hub was only reachable from the messages "+" FAB. Move it into the left navigation drawer as its own item right after Relay Groups, and make the same screen usable as a bottom-nav destination so users can pin it and reach their communities in one tap. - NavBarItem: add a CONCORD catalog entry (label "Concord Channels", Group icon, route Route.Concords) and insert it into DrawerFeedsItems right after RELAY_GROUPS. This renders the drawer row and lists it in the bottom-bar customization picker (which enumerates the whole catalog). - BottomBarFeedPreloaders: preload the Concord plane subscription when CONCORD is pinned, like every other bottom-bar feed. - ConcordHomeScreen: host AppBottomBar (auto-hides when pushed), show the back arrow only when canPop, and pad the create-FAB above the bar — so it works both as a pushed drawer destination and as a bottom-nav root. - ChannelFabColumn: drop the now-redundant Concord sub-FAB. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ui/navigation/bottombars/NavBarItem.kt | 9 ++++++ .../loggedIn/BottomBarFeedPreloaders.kt | 3 ++ .../concord/ConcordHomeScreen.kt | 31 ++++++++++++++----- .../loggedIn/chats/rooms/ChannelFabColumn.kt | 19 ------------ 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index aeeb9077c4..ea83801688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -67,6 +67,7 @@ enum class NavBarItem { PODCASTS, PUBLIC_CHATS, RELAY_GROUPS, + CONCORD, FOLLOW_PACKS, LIVE_STREAMS, NESTS, @@ -326,6 +327,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Forum, resolveRoute = { Route.RelayGroups }, ), + NavBarItem.CONCORD to + NavBarItemDef( + id = NavBarItem.CONCORD, + labelRes = R.string.concord_home_title, + icon = MaterialSymbols.Group, + resolveRoute = { Route.Concords }, + ), NavBarItem.FOLLOW_PACKS to NavBarItemDef( id = NavBarItem.FOLLOW_PACKS, @@ -448,6 +456,7 @@ val DrawerFeedsItems: List = NavBarItem.COMMUNITIES, NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, + NavBarItem.CONCORD, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SOFTWARE_APPS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index dab8a5916d..a05ddaf6ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.badges.datasource.BadgesFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.datasource.CalendarsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssemblerSubscription @@ -134,6 +135,8 @@ private fun PreloadFor( NavBarItem.RELAY_GROUPS -> RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + NavBarItem.CONCORD -> ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + NavBarItem.FOLLOW_PACKS -> FollowPacksFilterAssemblerSubscription(accountViewModel) NavBarItem.LIVE_STREAMS -> LiveStreamsFilterAssemblerSubscription(accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 817cb48416..9512863ff3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar +import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -112,19 +114,32 @@ fun ConcordHomeScreen( TopAppBar( title = { Text(stringRes(R.string.concord_home_title), fontWeight = FontWeight.Bold) }, navigationIcon = { - IconButton(onClick = { nav.popBack() }) { - SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + // Back arrow only when this is a pushed screen (from the drawer / a deep link); + // as a bottom-nav root there is nothing to pop and the bar takes its place. + if (nav.canPop()) { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } } }, ) }, + bottomBar = { + // Renders only when this is a bottom-nav root (AppBottomBar hides itself when canPop), + // so the same screen works both as a pushed destination and a bottom-nav tab. + AppBottomBar(Route.Concords, nav, accountViewModel) { route -> + if (route != Route.Concords) nav.navBottomBar(route) + } + }, floatingActionButton = { - FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }, shape = CircleShape) { - SymbolIcon( - symbol = MaterialSymbols.Add, - contentDescription = stringRes(R.string.concord_create_title), - modifier = Modifier.size(24.dp), - ) + FabBottomBarPadded(nav) { + FloatingActionButton(onClick = { nav.nav(Route.ConcordCreate) }, shape = CircleShape) { + SymbolIcon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(R.string.concord_create_title), + modifier = Modifier.size(24.dp), + ) + } } }, ) { padding -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt index 8231d83be8..d9eacb8d29 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChannelFabColumn.kt @@ -140,25 +140,6 @@ fun ChannelFabColumn(nav: INav) { } Spacer(modifier = Modifier.height(20.dp)) - - FloatingActionButton( - onClick = { - nav.nav(Route.Concords) - isOpen = false - }, - modifier = Size55Modifier, - shape = CircleShape, - containerColor = MaterialTheme.colorScheme.primary, - ) { - Text( - text = stringRes(R.string.concord_home_title), - color = Color.White, - textAlign = TextAlign.Center, - fontSize = Font12SP, - ) - } - - Spacer(modifier = Modifier.height(20.dp)) } } From 56d3d0681c634f1823b6e54b0042ae5da215c1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:03:07 +0000 Subject: [PATCH 081/115] feat(concord): drop the redundant rail and make the hub feel alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A — remove the top community rail. It showed the same community icons that already appear as the list rows below, and its only action (toggle the accordion) duplicated tapping a row. The accordion list is self-sufficient. Make it alive — surface the activity we already fold, no new subscriptions: - Activity sort: communities and channels order by their most-recent message (Channel.lastNote), so the ones you'd actually open float to the top. - Unread: a per-channel bold + dot and a per-community count badge, read from the last-read the open channel already persists. Extracted a shared concordChannelLastReadRoute() so the write (ConcordChannelScreen) and read (hub) sides can't drift. - Last-message preview: author + snippet + relative time under each channel. - Banner hero: render the community's CORD-02 §6 encrypted banner when expanded (the maintainer's rememberConcordImageModel already resolves it). Deferred (need plumbing that doesn't exist yet): voice presence (kind 23313 is modeled in quartz but has no consumer), typing (23311, not implemented), and a true member count (needs Guestbook membership, not subscribed here). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordChannelScreen.kt | 2 +- .../concord/ConcordHomeScreen.kt | 324 ++++++++++++------ .../publicChannels/concord/ConcordLastRead.kt | 32 ++ 3 files changed, 255 insertions(+), 103 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 9cdd239308..6db7c434cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -165,7 +165,7 @@ fun ConcordChannelScreen( feedContentState = feedViewModel.feedState, accountViewModel = accountViewModel, nav = nav, - routeForLastRead = "Concord/$communityId/$channelId", + routeForLastRead = concordChannelLastReadRoute(communityId, channelId), onWantsToReply = { newMessageModel.reply(it) }, onWantsToEditDraft = {}, // A status card at the oldest end: shows what it's reaching for while it pages and diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 9512863ff3..ca968ec18a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -20,19 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord -import androidx.compose.foundation.border +import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -52,7 +51,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -61,25 +61,29 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** * The Concord Channels hub — a single-screen browser of every community the account - * joined (kind-13302) and, expanded inline, that community's channels. A community - * rail across the top jumps to (and expands) any server; each row in the list below - * is a community you can expand to reveal its `#`/🔒/🎙 channels without leaving the - * screen. Tapping a channel opens its chat; the community header opens the full - * server view. + * joined (kind-13302) and, expanded inline, that community's channels. Each row is a + * community you can expand to reveal its `#`/🔒/🎙 channels without leaving the screen. + * Tapping a channel opens its chat; the community header opens the full server view. * * Concord has no public directory — communities are E2E-encrypted and invite-gated — * so there's no browse feed: you arrive by creating one, redeeming an invite, or (for @@ -155,107 +159,119 @@ fun ConcordHomeScreen( return@Scaffold } - Column(Modifier.fillMaxSize().padding(padding)) { - // Community rail: every joined community as an avatar; tap toggles its channels below. - CommunityRail( - communities = communities, - revision = revision, - expanded = expanded, - accountViewModel = accountViewModel, - onToggle = { id -> expanded = if (id in expanded) expanded - id else expanded + id }, - ) - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - - LazyColumn(Modifier.fillMaxSize()) { - communities.forEach { entry -> - val state = + // Busiest communities first: sort by the most-recent message across their channels so the + // one you'd actually open floats to the top (recomputed as messages fold in on `revision`). + val sorted = + remember(communities, revision) { + communities.sortedByDescending { entry -> + val keys = account.concordSessions .sessionFor(entry.id) ?.state ?.value - .takeIf { revision >= 0 } - val isOpen = entry.id in expanded + ?.channels + ?.keys + .orEmpty() + communityActivity(entry.id, keys) + } + } - item(key = entry.id) { - CommunityHeader( + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + sorted.forEach { entry -> + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + .takeIf { revision >= 0 } + val isOpen = entry.id in expanded + val channelKeys = state?.channels?.keys.orEmpty() + + item(key = entry.id) { + CommunityHeader( + communityId = entry.id, + name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, + iconPointer = state?.metadata?.icon, + channelKeys = channelKeys, + revision = revision, + expanded = isOpen, + accountViewModel = accountViewModel, + onToggle = { expanded = if (isOpen) expanded - entry.id else expanded + entry.id }, + onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, + ) + } + + if (isOpen && state != null) { + // A banner hero (CORD-02 §6), when the community set one — pops in once decrypted. + state.metadata?.banner?.let { banner -> + item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) } + } + // Channels, most-recently-active first, each with its last message + unread state. + val channels = + state.channels.entries.sortedByDescending { + LocalCache.getConcordChannelIfExists(ConcordChannelId(entry.id, it.key))?.lastNote?.createdAt() ?: 0L + } + items(channels, key = { "${entry.id}/${it.key}" }) { ch -> + val def = ch.value.definition + ConcordChannelRow( communityId = entry.id, - name = state?.metadata?.name?.takeIf { it.isNotBlank() } ?: entry.name.ifBlank { stringRes(R.string.concord_home_title) }, - iconPointer = state?.metadata?.icon, - channelCount = state?.channels?.size ?: 0, - expanded = isOpen, + channelKey = ch.key, + channelName = def?.name ?: ch.key, + icon = + when { + def?.voice == true -> MaterialSymbols.Mic + def?.private == true -> MaterialSymbols.Lock + else -> MaterialSymbols.Tag + }, + revision = revision, accountViewModel = accountViewModel, - onToggle = { expanded = if (isOpen) expanded - entry.id else expanded + entry.id }, - onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, + onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, ) } - - if (isOpen && state != null) { - val channels = state.channels.entries.toList() - items(channels, key = { "${entry.id}/${it.key}" }) { ch -> - val def = ch.value.definition - ChannelSubRow( - name = def?.name ?: ch.key, - icon = - when { - def?.voice == true -> MaterialSymbols.Mic - def?.private == true -> MaterialSymbols.Lock - else -> MaterialSymbols.Tag - }, - onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, - ) - } - } - item(key = "div-${entry.id}") { - HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) - } + } + item(key = "div-${entry.id}") { + HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } } } } } +/** Most-recent message time across a community's [channelKeys] (0 if none), for activity sorting. */ +private fun communityActivity( + communityId: String, + channelKeys: Set, +): Long = + channelKeys.maxOfOrNull { key -> + LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L + } ?: 0L + +/** + * The number of a community's channels with a message newer than this account last read there — + * combines each channel's persisted last-read ([concordChannelLastReadRoute]) against its + * [com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel.lastNote]. Recomputed on + * [revision] so a freshly-folded message flips the badge. + */ @Composable -private fun CommunityRail( - communities: List, +private fun communityUnreadCount( + account: Account, + communityId: String, + channelKeys: Set, revision: Int, - expanded: Set, - accountViewModel: AccountViewModel, - onToggle: (String) -> Unit, -) { - val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() - LazyRow( - Modifier.fillMaxWidth().padding(vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - items(communities, key = { it.id }) { entry -> - val iconPointer = - accountViewModel.account.concordSessions - .sessionFor(entry.id) - ?.state - ?.value - ?.metadata - ?.icon - .takeIf { revision >= 0 } - val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) - val isOpen = entry.id in expanded - val ring = if (isOpen) MaterialTheme.colorScheme.primary else Color.Transparent - RobohashFallbackAsyncImage( - robot = entry.id, - model = iconModel, - contentDescription = entry.name, - modifier = - Modifier - .size(48.dp) - .clip(CircleShape) - .border(2.dp, ring, CircleShape) - .clickable { onToggle(entry.id) }, - loadProfilePicture = accountViewModel.settings.showProfilePictures(), - loadRobohash = accountViewModel.settings.isNotPerformanceMode(), - autoPlayGif = autoPlayGif, - ) +): Int { + if (channelKeys.isEmpty()) return 0 + val flow = + remember(communityId, channelKeys, revision) { + combine( + channelKeys.map { key -> + account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)).map { lastRead -> + val last = LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L + if (last > lastRead) 1 else 0 + } + }, + ) { flags -> flags.sum() } } - } + return flow.collectAsStateWithLifecycle(0).value } @Composable @@ -263,7 +279,8 @@ private fun CommunityHeader( communityId: String, name: String, iconPointer: ImagePointer?, - channelCount: Int, + channelKeys: Set, + revision: Int, expanded: Boolean, accountViewModel: AccountViewModel, onToggle: () -> Unit, @@ -271,6 +288,7 @@ private fun CommunityHeader( ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) + val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys, revision) Row( modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, @@ -286,15 +304,22 @@ private fun CommunityHeader( autoPlayGif = autoPlayGif, ) Column(Modifier.weight(1f)) { - Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (channelCount > 0) { + Text( + name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (channelKeys.isNotEmpty()) { Text( - pluralStringResource(R.plurals.concord_channel_count, channelCount, channelCount), + pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + if (unread > 0) UnreadBadge(unread) SymbolIcon( symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, contentDescription = null, @@ -304,22 +329,117 @@ private fun CommunityHeader( } } +/** The community's decrypted CORD-02 §6 banner as a hero strip; renders nothing until it resolves. */ @Composable -private fun ChannelSubRow( - name: String, +private fun CommunityBanner( + banner: ImagePointer, + accountViewModel: AccountViewModel, +) { + val model = rememberConcordImageModel(banner, accountViewModel) ?: return + val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + RobohashFallbackAsyncImage( + robot = "", + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().height(110.dp), + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = false, + autoPlayGif = autoPlayGif, + ) +} + +/** A small pill showing the unread-channel count next to a community. */ +@Composable +private fun UnreadBadge(count: Int) { + Box( + modifier = + Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 7.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + Text( + count.toString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +/** + * One channel row with its last message (author + snippet), relative time, and an unread marker — + * bold + a dot when there's a message newer than this account last read the channel. + */ +@Composable +private fun ConcordChannelRow( + communityId: String, + channelKey: String, + channelName: String, icon: MaterialSymbol, + revision: Int, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { + val account = accountViewModel.account + val channel = remember(communityId, channelKey) { LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, channelKey)) } + val lastNote = remember(revision, channel) { channel?.lastNote } + val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() + val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime + Row( Modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(start = 40.dp, end = 16.dp) - .padding(vertical = 11.dp), + .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - SymbolIcon(symbol = icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + SymbolIcon( + symbol = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Column(Modifier.weight(1f)) { + Text( + channelName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val note = lastNote + val event = note?.event + val author = note?.author + val preview: String? = + if (author != null && event != null) { + val authorName by observeUserName(author, accountViewModel) + "$authorName: ${event.content.take(80)}" + } else { + event?.content?.take(80) + } + preview?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + lastNote?.createdAt()?.let { ts -> + Text( + timeAgo(ts, LocalContext.current, prefix = ""), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (unread) { + Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt new file mode 100644 index 0000000000..65b819fd52 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordLastRead.kt @@ -0,0 +1,32 @@ +/* + * 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 + +/** + * The per-account last-read route key for one Concord channel — the string + * [com.vitorpamplona.amethyst.model.Account.markAsRead]/`loadLastReadFlow` are keyed by. + * Shared by the write side (the open channel marks messages read as they show) and the + * read side (the hub's unread indicators) so the two can never drift apart. + */ +fun concordChannelLastReadRoute( + communityId: String, + channelId: String, +): String = "Concord/$communityId/$channelId" From 1a1389e9ef376164f11ebdff7788324195ac7bea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:13:43 +0000 Subject: [PATCH 082/115] feat(concord): tri-state channel expansion (closed / unread peek / all) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a community header now cycles three states instead of two: CLOSED → UNREAD (peek only the channels with new messages) → OPEN (all) → CLOSED The UNREAD peek is skipped when a community has nothing unread, so a quiet community goes straight CLOSED → OPEN → CLOSED and never lands on an empty middle state. In the peek, read channels hide themselves (reactively, off each channel's last-read) and a "Show all channels" footer jumps to the full view; the chevron shows ▲ only when fully open (▼ otherwise = "more to reveal"), and the banner hero is reserved for the full-open view. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordHomeScreen.kt | 87 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index ca968ec18a..1cfd88055d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -110,8 +110,9 @@ fun ConcordHomeScreen( // the stock relays for users who actually use Concord. LaunchedEffect(Unit) { accountViewModel.importConcordCommunities() } - // Communities expanded in the accordion (multi-open, so several can show channels at once). - var expanded by remember { mutableStateOf(emptySet()) } + // Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with + // new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once. + var expandStates by remember { mutableStateOf(emptyMap()) } Scaffold( topBar = { @@ -184,7 +185,7 @@ fun ConcordHomeScreen( ?.state ?.value .takeIf { revision >= 0 } - val isOpen = entry.id in expanded + val mode = expandStates[entry.id] ?: ChannelExpand.CLOSED val channelKeys = state?.channels?.keys.orEmpty() item(key = entry.id) { @@ -194,19 +195,22 @@ fun ConcordHomeScreen( iconPointer = state?.metadata?.icon, channelKeys = channelKeys, revision = revision, - expanded = isOpen, + mode = mode, accountViewModel = accountViewModel, - onToggle = { expanded = if (isOpen) expanded - entry.id else expanded + entry.id }, + onSetMode = { next -> expandStates = if (next == ChannelExpand.CLOSED) expandStates - entry.id else expandStates + (entry.id to next) }, onOpen = { nav.nav(Route.ConcordServer(entry.id)) }, ) } - if (isOpen && state != null) { - // A banner hero (CORD-02 §6), when the community set one — pops in once decrypted. - state.metadata?.banner?.let { banner -> - item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) } + if (mode != ChannelExpand.CLOSED && state != null) { + // A banner hero (CORD-02 §6) belongs to the full view, not the compact unread peek. + if (mode == ChannelExpand.OPEN) { + state.metadata?.banner?.let { banner -> + item(key = "banner-${entry.id}") { CommunityBanner(banner, accountViewModel) } + } } // Channels, most-recently-active first, each with its last message + unread state. + // In UNREAD mode a row hides itself unless it has new messages (peek). val channels = state.channels.entries.sortedByDescending { LocalCache.getConcordChannelIfExists(ConcordChannelId(entry.id, it.key))?.lastNote?.createdAt() ?: 0L @@ -224,10 +228,16 @@ fun ConcordHomeScreen( else -> MaterialSymbols.Tag }, revision = revision, + hideIfRead = mode == ChannelExpand.UNREAD, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, ) } + if (mode == ChannelExpand.UNREAD) { + item(key = "showall-${entry.id}") { + ShowAllChannelsRow(onClick = { expandStates = expandStates + (entry.id to ChannelExpand.OPEN) }) + } + } } item(key = "div-${entry.id}") { HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) @@ -281,16 +291,24 @@ private fun CommunityHeader( iconPointer: ImagePointer?, channelKeys: Set, revision: Int, - expanded: Boolean, + mode: ChannelExpand, accountViewModel: AccountViewModel, - onToggle: () -> Unit, + onSetMode: (ChannelExpand) -> Unit, onOpen: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys, revision) + // Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread + // (so a quiet community never lands on an empty middle state). + val next = + when (mode) { + ChannelExpand.CLOSED -> if (unread > 0) ChannelExpand.UNREAD else ChannelExpand.OPEN + ChannelExpand.UNREAD -> ChannelExpand.OPEN + ChannelExpand.OPEN -> ChannelExpand.CLOSED + } Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.fillMaxWidth().clickable { onSetMode(next) }.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -321,7 +339,8 @@ private fun CommunityHeader( } if (unread > 0) UnreadBadge(unread) SymbolIcon( - symbol = if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + // ▲ only when fully open; ▼ for both CLOSED and the UNREAD peek ("more to reveal"). + symbol = if (mode == ChannelExpand.OPEN) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -379,6 +398,7 @@ private fun ConcordChannelRow( channelName: String, icon: MaterialSymbol, revision: Int, + hideIfRead: Boolean, accountViewModel: AccountViewModel, onClick: () -> Unit, ) { @@ -388,6 +408,9 @@ private fun ConcordChannelRow( val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime + // In the UNREAD peek, a read channel simply isn't shown. + if (hideIfRead && !unread) return + Row( Modifier .fillMaxWidth() @@ -443,3 +466,41 @@ private fun ConcordChannelRow( } } } + +/** The footer in the UNREAD peek that expands a community to all of its channels. */ +@Composable +private fun ShowAllChannelsRow(onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(start = 40.dp, end = 16.dp) + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + SymbolIcon( + symbol = MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + stringRes(R.string.concord_show_all_channels), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } +} + +/** How much of a community's channel list the hub shows, cycled by tapping its header. */ +private enum class ChannelExpand { + /** Header only. */ + CLOSED, + + /** Only channels with messages newer than this account last read them (the "peek"). */ + UNREAD, + + /** Every channel, plus the banner hero. */ + OPEN, +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c49ecf87ed..6339b6d884 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -310,6 +310,7 @@ Concord Channels You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. + Show all channels New Concord Channel Name About (optional) From 9f55794e06cb0dda94f0455bce8221fc69a0a987 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 18:22:17 -0400 Subject: [PATCH 083/115] fix(concord): only bump session revision on structural change, not per message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every inbound plane wrap that a session claimed — including a plain chat message — bumped ConcordSessionManager.revision, and the always-on preload, the channel subscription, and the open-channel history subscription each call invalidateFilters() on every bump. So every message re-derived and re-REQ'd every community's control + channel planes. On a cold load of hundreds of buffered messages that is hundreds of re-subscriptions, which the relays answer with "there is a bug in the client, no one should be making so many requests" and close the plane subs mid-load (each needing a fresh NIP-42 AUTH). The result: channels load only their last few messages, or none. ingest() now reports a ConcordIngestOutcome (NOT_MINE / NON_STRUCTURAL / STRUCTURAL). Only a STRUCTURAL wrap — a Control-Plane fold, a guestbook membership change, or a buffered base-rekey — bumps the revision. Chat messages are NON_STRUCTURAL: they still reach the feed via the rumor sink → LocalCache, but no longer churn the subscriptions. The manager keeps its Boolean contract (claimed) for DecryptAndIndexProcessor. Verified on-device: the "so many requests" rate-limit is gone and the plane subscription stays open and drains steadily instead of being closed and reopened per message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 6 +-- .../model/concord/ConcordCommunitySession.kt | 52 +++++++++++++++---- .../model/concord/ConcordSessionManager.kt | 14 +++-- .../model/concord/ConcordSessionRegistry.kt | 13 ++--- .../concord/ConcordCommunitySessionTest.kt | 15 +++--- .../concord/ConcordSessionRegistryTest.kt | 9 ++-- 6 files changed, 75 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 2ad44d3178..89d8286c8d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -5022,9 +5022,9 @@ class Account( // Keep Concord channel metadata (community name/icon, membership) live across the whole // app — not just the hub screen — so the Messages tab renders each channel's community - // chip, and per-community bans apply, as soon as a Control Plane folds. The revision bumps - // on every ingested message, so sample() coalesces bursts into at most one full re-index - // per window instead of re-scanning every channel's notes per message. + // chip, and per-community bans apply, as soon as a Control Plane folds. The revision now + // bumps only on *structural* change (a fold / membership / rekey, never a plain message), + // so this fires rarely; sample() stays as a cheap coalescer for a burst of folds. scope.launch { @OptIn(kotlinx.coroutines.FlowPreview::class) concordSessions.revision.sample(500).collect { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index a3b2574ed3..5c599f3c60 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -41,6 +41,29 @@ import kotlinx.coroutines.flow.StateFlow */ typealias ConcordRumorSink = (communityId: HexKey, channelIdHex: HexKey, rumor: Event) -> Unit +/** + * The result of feeding one wrap to a session's [ConcordCommunitySession.ingest]. It separates + * "was it ours" from "did it change structure", so only structure-changing wraps bump the session + * revision (and thus re-derive plane subscriptions). Landing every chat message as a revision bump + * re-REQs every plane per message and gets the client rate-limited off the relays. + */ +enum class ConcordIngestOutcome { + /** The wrap is not addressed to any plane this session knows. Keep routing it elsewhere. */ + NOT_MINE, + + /** Ours and applied, but nothing the subscription set / folded structure depends on changed — + * a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */ + NON_STRUCTURAL, + + /** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a + * guestbook membership change, or a buffered base-rekey. Bumps the revision. */ + STRUCTURAL, + ; + + /** True when the wrap belonged to this session (whether or not it changed structure). */ + val claimed get() = this != NOT_MINE +} + /** * The live read-model of one joined Concord community, driven by inbound stream * wraps fed via [ingest]. @@ -155,38 +178,47 @@ class ConcordCommunitySession( /** * Ingests a stream [wrap]. If it belongs to this community's Control Plane it * re-folds; if it belongs to a known channel plane it re-projects that - * channel's messages. Returns true if the wrap was recognized and applied. + * channel's messages. The [ConcordIngestOutcome] tells the caller both whether + * the wrap was ours and — crucially — whether it changed *structure* (a fold that + * moves the subscription set / metadata) versus just landing a chat message. Only + * a [ConcordIngestOutcome.STRUCTURAL] result should bump the session revision; + * bumping on every message re-derives every plane's REQ per message and rate-limits + * the relays (they close the plane subs mid-load, so channels appear empty). */ - fun ingest(wrap: Event): Boolean { + fun ingest(wrap: Event): ConcordIngestOutcome { when (wrap.pubKey) { controlPlaneAddress -> { lock.withLock { - if (controlWraps.put(wrap.id, wrap) != null) return true // dup + if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup } refold() - return true + return ConcordIngestOutcome.STRUCTURAL } guestbookAddress -> { lock.withLock { - if (guestbookWraps.put(wrap.id, wrap) != null) return true // dup + if (guestbookWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup } refoldGuestbook() - return true + return ConcordIngestOutcome.STRUCTURAL } nextBaseRekeyAddress -> { // Buffer only — decrypting a base-rotation blob needs the account signer, so the - // app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. + // app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That + // drain runs off the revision tick, so a buffered rekey must bump (rare — a rekey, + // not a message). lock.withLock { baseRekeyWraps[wrap.id] = wrap } - return true + return ConcordIngestOutcome.STRUCTURAL } else -> { - val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return false + val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE val (channelIdHex, _) = channelRef lock.withLock { channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) } reprojectChannel(channelIdHex) - return true + // A chat message lands in the feed via [onRumor] → LocalCache, independent of the + // revision; it changes no plane address, so it must NOT bump (see the storm note above). + return ConcordIngestOutcome.NON_STRUCTURAL } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 8a6fcb2065..32268d4f18 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -123,11 +123,17 @@ class ConcordSessionManager( return out } - /** Route an inbound stream wrap; true if it was a Concord plane wrap we applied. */ + /** + * Route an inbound stream wrap; true if it was a Concord plane wrap we applied. Only a wrap that + * changed community *structure* (a fold, a membership/rekey change — not a plain chat message) + * bumps the revision: bumping per message re-derives every plane subscription per message and + * gets the client rate-limited off the relays (which then close the plane subs mid-load). Chat + * messages still reach the feed via the rumor sink → LocalCache, independent of the revision. + */ fun ingest(wrap: Event): Boolean { - val applied = registry.ingest(wrap) - if (applied) bumpRevision() - return applied + val outcome = registry.ingest(wrap) + if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision() + return outcome.claimed } fun sessions() = registry.sessions() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index b6ff4c4039..800b299b4f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -98,16 +98,17 @@ class ConcordSessionRegistry( } /** - * Routes an inbound stream [wrap] to whichever session recognizes it. Returns - * true if some session applied it. A wrap belongs to at most one plane, so the - * first accepting session wins. + * Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's + * [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to + * at most one plane, so the first accepting session wins. */ - fun ingest(wrap: Event): Boolean { + fun ingest(wrap: Event): ConcordIngestOutcome { val snapshot = lock.withLock { sessions.values.toList() } for (session in snapshot) { - if (session.ingest(wrap)) return true + val outcome = session.ingest(wrap) + if (outcome != ConcordIngestOutcome.NOT_MINE) return outcome } - return false + return ConcordIngestOutcome.NOT_MINE } fun clear() = lock.withLock { sessions.clear() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index f6090a8207..9477b8cb18 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -54,8 +54,9 @@ class ConcordCommunitySessionTest { val session = ConcordCommunitySession(entry, owner.pubKey) { communityId, channelIdHex, rumor -> captured += Triple(communityId, channelIdHex, rumor) } assertEquals(community.controlPlane.publicKeyHex, session.controlPlaneAddress) - // Feed the genesis control wraps → state folds, channels + membership resolve. - community.genesisWraps.forEach { assertTrue(session.ingest(it)) } + // Feed the genesis control wraps → state folds, channels + membership resolve. A fold is + // STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision. + community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) } val state = session.state.value assertEquals("Nostrichs", state?.metadata?.name) assertTrue(state!!.channels.containsKey(community.generalChannelIdHex)) @@ -67,7 +68,9 @@ class ConcordCommunitySessionTest { // A channel message wrap decrypts and is emitted to the sink for #general. val msgWrap = ConcordActions.buildChannelMessage(owner, general, community.generalChannelIdHex, community.rootEpoch, "gm all", 2L) - assertTrue(session.ingest(msgWrap)) + // A chat message lands in the feed but is NON_STRUCTURAL: it must never bump the revision + // (per-message re-subscription is what rate-limited the plane REQs and emptied channels). + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msgWrap)) val general9 = captured.filter { it.second == community.generalChannelIdHex && it.third.content == "gm all" } assertEquals(1, general9.size) assertEquals(community.communityIdHex, general9[0].first) @@ -76,7 +79,7 @@ class ConcordCommunitySessionTest { // 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)) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, 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]) @@ -85,7 +88,7 @@ class ConcordCommunitySessionTest { // root and lowercase `e` at the immediate parent (both the message here), still bound // to the channel so it groups into the message's thread — the shape Armada threads. val replyWrap = ConcordActions.buildChannelReply(owner, general, community.generalChannelIdHex, community.rootEpoch, message, "gm back", 4L) - assertTrue(session.ingest(replyWrap)) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(replyWrap)) val reply = captured.map { it.third }.first { it.content == "gm back" } assertEquals(1111, reply.kind) assertEquals(message.id, reply.tags.first { it[0] == "E" }[1]) @@ -94,6 +97,6 @@ class ConcordCommunitySessionTest { // A stray wrap from a different community is ignored. val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) - assertTrue(!session.ingest(outsider.genesisWraps.first())) + assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first())) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt index f9cc66eecf..b5df7c332a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -70,8 +69,8 @@ class ConcordSessionRegistryTest { assertTrue(registry.subscribeAddresses().contains(alpha.controlPlane.publicKeyHex)) assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) - // A genesis control wrap routes to Alpha's session and folds it. - alpha.genesisWraps.forEach { assertTrue(registry.ingest(it)) } + // A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL). + alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) } val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value assertEquals("Alpha", alphaState?.metadata?.name) @@ -81,7 +80,7 @@ class ConcordSessionRegistryTest { // A channel message decrypts and is emitted to the sink for Alpha's #general. val msg = ConcordActions.buildChannelMessage(owner, general, alpha.generalChannelIdHex, alpha.rootEpoch, "gm", 2L) - assertTrue(registry.ingest(msg)) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, registry.ingest(msg)) val general9 = captured.filter { it.first == alpha.communityIdHex && it.second == alpha.generalChannelIdHex && it.third.content == "gm" } assertEquals(1, general9.size) @@ -101,6 +100,6 @@ class ConcordSessionRegistryTest { // A wrap from an unknown community is routed nowhere. val gamma = ConcordCommunityFactory.create(owner, "Gamma", createdAt = 1L, relays = listOf("wss://r.example")) - assertFalse(registry.ingest(gamma.genesisWraps.first())) + assertEquals(ConcordIngestOutcome.NOT_MINE, registry.ingest(gamma.genesisWraps.first())) } } From 66a5e111d6478040b240403a31dcb9076feb5585 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 18:22:32 -0400 Subject: [PATCH 084/115] fix(concord): eagerly backfill an open channel's history to a target window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live plane subscription only carries each channel's relay-capped recent tail, shared across one merged REQ per relay for every channel, so a channel with plenty of history opened showing just its last few messages until the user scrolled. The old bootstrap only paged when the feed was completely empty. ConcordBackfillHistoryToWindow now pages older history on open until the feed holds at least CONCORD_HISTORY_TARGET (50) messages or the relays are exhausted — mirroring Armada's multi-page backfillStore — page by page via the existing BackwardRelayPager, then latches off and lets scrolling drive further paging. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordChannelScreen.kt | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 9cdd239308..6bdf834ca0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -83,10 +83,14 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -134,7 +138,7 @@ fun ConcordChannelScreen( } } } - ConcordBootstrapHistoryWhenEmpty(feedViewModel.feedState, history) + ConcordBackfillHistoryToWindow(feedViewModel.feedState, history) val newMessageModel: ConcordNewMessageViewModel = viewModel(key = channel.channelId.toKey() + "ConcordNewMessageViewModel") newMessageModel.init(accountViewModel) @@ -215,23 +219,40 @@ fun ConcordChannelScreen( } } +/** The number of messages a freshly-opened channel eagerly backfills to before paging goes demand-driven. */ +private const val CONCORD_HISTORY_TARGET = 50 + /** - * When the channel opens empty, kick a single history page so there's something to scroll from — from - * there paging is purely demand-driven by the markers' visibility. Debounced so the transient empty - * feed that navigation flashes through doesn't trigger a hunt. Mirrors the DM `BootstrapHistoryWhenEmpty`. + * On open, eagerly backfill this channel's older history until the feed holds at least + * [CONCORD_HISTORY_TARGET] messages (or the relays are exhausted) — mirroring Armada's multi-page + * `backfillStore`. The live subscription only carries each plane's relay-capped recent tail, shared + * across one merged REQ per relay for every channel; so without this, a channel that has plenty of + * history opens showing just its last few messages until the user scrolls. Once the target is reached, + * paging is purely demand-driven by the markers' visibility. A short startup delay skips the transient + * empty feed that navigation flashes through. Supersedes the old empty-only bootstrap. */ +@OptIn(ExperimentalCoroutinesApi::class) @Composable -private fun ConcordBootstrapHistoryWhenEmpty( +private fun ConcordBackfillHistoryToWindow( feedContentState: FeedContentState, history: ConcordChannelHistorySubAssembler, ) { - val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() - val needsBootstrap = feedState is FeedState.Empty - LaunchedEffect(needsBootstrap, history) { - if (!needsBootstrap) return@LaunchedEffect + LaunchedEffect(feedContentState, history) { delay(1200L) - combine(history.loadingMore, history.status) { loading, s -> !loading && !s.exhausted } - .distinctUntilChanged() + // Reactive count of currently-loaded messages (0 while empty/loading). + val loadedCount = + feedContentState.feedContent.flatMapLatest { state -> + when (state) { + is FeedState.Loaded -> state.feed.map { it.list.size } + else -> flowOf(0) + } + } + // Pull another page whenever we're below target, no page is in flight, and relays aren't done. + // Each landed page grows the count (or flips exhausted), so this re-fires page-by-page and then + // latches off. The !loading gate prevents overlapping REQs / a tight loop. + combine(loadedCount, history.loadingMore, history.status) { count, loading, status -> + count < CONCORD_HISTORY_TARGET && !loading && !status.exhausted + }.distinctUntilChanged() .filter { it } .collect { history.advanceAll() } } From 5de55741a28de68bb7c0a41bccc562673440bd3d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 18:22:33 -0400 Subject: [PATCH 085/115] feat(concord): reuse the standard compressed/encrypted upload pipeline for community images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConcordImageUploader now drives UploadOrchestrator.uploadEncrypted — the same path DM/chat encrypted media uses — instead of a hand-rolled BlossomUploader call. A community icon now gets image compression, EXIF/metadata stripping, and the account's configured Blossom server, keeping the simple photo picker. It hands the orchestrator a fresh AESGCM cipher and maps the result — ciphertext url + plaintext hashBeforeEncryption — into the CORD-02 §6 ImagePointer, which the read path (rememberConcordImageModel) round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordImageUploader.kt | 124 +++++++++--------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt index 5b3c7af3be..6ec70492a0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -22,80 +22,86 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import android.content.Context import android.net.Uri -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.service.uploads.CompressorQuality +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.ciphers.AESGCM -import com.vitorpamplona.quartz.utils.sha256.sha256 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.ByteArrayInputStream /** - * Authors a CORD-02 §6 encrypted community image: AES-256-GCM-encrypts the plaintext under a fresh - * random key/nonce (same scheme as NIP-17 DM encrypted media), uploads the *ciphertext* as an opaque - * blob to the account's Blossom server, and returns the [ImagePointer] to seal in the community - * metadata — the exact inverse of [rememberConcordImageModel]'s read path. Mirrors Armada's - * `encryptImageBlob` + Blossom upload in `concord-v2/lib/image.ts`. + * Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the + * community metadata — the exact inverse of [rememberConcordImageModel]'s read path, and the same + * scheme Armada's `encryptImageBlob` + Blossom upload uses in `concord-v2/lib/image.ts`. + * + * Rather than hand-roll the upload, this drives the **standard** [UploadOrchestrator.uploadEncrypted] + * pipeline that DM/chat encrypted media uses, so a community icon gets the same treatment as any + * other attachment: image compression ([CompressorQuality]), EXIF/metadata stripping, and the + * account's configured Blossom server. The only Concord-specific parts are the fresh [AESGCM] cipher + * (whose key/nonce we keep to build the pointer) and mapping the orchestrator's result — the + * ciphertext `url` plus the *plaintext* `hashBeforeEncryption` — into the [ImagePointer] shape. */ class ConcordImageUploader( private val account: Account, ) { - suspend fun uploadEncrypted( - plaintext: ByteArray, - context: Context, - ): ImagePointer { - val serverBaseUrl = - account.blossomServers - .getBlossomServersList() - ?.servers() - ?.firstOrNull() - ?: DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom }.baseUrl - - val cipher = AESGCM() - val ciphertext = cipher.encrypt(plaintext) - - val result = - withContext(Dispatchers.IO) { - BlossomUploader().upload( - // The blob is content-addressed by the SHA-256 of the *uploaded* (encrypted) bytes; - // the pointer's own hash below is over the *plaintext* for integrity on read. - inputStream = ByteArrayInputStream(ciphertext), - hash = sha256(ciphertext).toHexKey(), - length = ciphertext.size.toLong(), - baseFileName = "concord-image", - contentType = "application/octet-stream", - alt = "Encrypted Concord community image", - sensitiveContent = null, - serverBaseUrl = serverBaseUrl, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - httpAuth = account::createBlossomUploadAuth, - context = context, - ) - } - - val url = result.url ?: throw IllegalStateException("Blossom upload returned no URL") - return ImagePointer( - url = url, - key = cipher.keyBytes.toHexKey(), - nonce = cipher.nonce.toHexKey(), - hash = sha256(plaintext).toHexKey(), - ) - } - - /** Reads the picked [uri]'s bytes then [uploadEncrypted]s them. */ + /** Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. */ suspend fun uploadEncrypted( uri: Uri, context: Context, ): ImagePointer { - val bytes = - withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - } ?: throw IllegalStateException("Could not read the selected image") - return uploadEncrypted(bytes, context) + // Fresh random key + nonce per image; we hold onto them to build the pointer below since the + // orchestrator only surfaces the ciphertext URL, not the cipher it was handed. + val cipher = AESGCM() + + val finalState = + UploadOrchestrator().uploadEncrypted( + uri = uri, + mimeType = context.contentResolver.getType(uri), + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.MEDIUM, + encrypt = cipher, + server = resolveBlossomServer(), + account = account, + context = context, + ) + + val result = + when (finalState) { + is UploadingState.Finished -> finalState.result + is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params)) + } + + val server = + result as? UploadOrchestrator.OrchestratorResult.ServerResult + ?: throw IllegalStateException("Encrypted community image upload did not return a server URL") + + return ImagePointer( + url = server.url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + // hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it + // after decrypting, so it must match what was actually encrypted, not the original file. + hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"), + ) + } + + /** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */ + private fun resolveBlossomServer(): ServerName { + val configured = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() + return if (configured != null) { + ServerName(configured, configured, ServerType.Blossom) + } else { + DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom } + } } } From b7df458071f229754fa8f91346295b2993f2628a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:23:41 +0000 Subject: [PATCH 086/115] feat(concord): true member count from the re-enabled Guestbook plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Guestbook membership fold was already written but dormant: I had decoupled its plane from the shared REQ + AUTH while chasing the empty-channels regression. The maintainer's `re-authenticate on an auth-required CLOSED` fix addresses that root cause, and the Guestbook + next-rekey stream keys derive from the entry alone (so they AUTH on the initial connection, unlike channel keys that appear only after the Control Plane folds). Re-enable them: - streamAuthSecretsFor now also signs the aux (Guestbook + next-rekey) stream keys; the assembler re-adds auxiliaryPlaneSubs to the plane subscription. - ConcordCommunitySession.allMembers()/memberCount(): Guestbook joins ∪ owner ∪ role-holders, minus banned — a best-effort floor (a silent key-holder who never posted a join and holds no role is invisible). - Surface it: the hub community header subtitle shows "N channels · M members", and the Members screen lists the Guestbook members alongside owner/admins/banned. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordHomeScreen.kt | 16 +++++++++++++-- .../concord/ConcordMembersScreen.kt | 11 ++++++++-- .../ConcordChannelFilterAssembler.kt | 10 +++++----- amethyst/src/main/res/values/strings.xml | 4 ++++ .../model/concord/ConcordCommunitySession.kt | 16 +++++++++++++++ .../model/concord/ConcordSessionManager.kt | 20 ++++++++++++------- 6 files changed, 61 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 1cfd88055d..517d14fdf4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -329,11 +329,23 @@ private fun CommunityHeader( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (channelKeys.isNotEmpty()) { + val memberCount = + remember(revision) { + accountViewModel.account.concordSessions + .sessionFor(communityId) + ?.memberCount() ?: 0 + } + val parts = mutableListOf() + if (channelKeys.isNotEmpty()) parts += pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size) + if (memberCount > 0) parts += pluralStringResource(R.plurals.concord_member_count, memberCount, memberCount) + val subtitle = parts.joinToString(" · ") + if (subtitle.isNotEmpty()) { Text( - pluralStringResource(R.plurals.concord_channel_count, channelKeys.size, channelKeys.size), + subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index e32225d6ef..4f24c1007c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -94,12 +94,19 @@ fun ConcordMembersScreen( val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() + // The Guestbook membership (self-signed joins), so plain members show alongside the owner, + // admins and banned — not just the privileged roster the Control Plane traces. + val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet()) }).collectAsStateWithLifecycle() + val myPubKey = account.signer.pubKey val roster = - remember(state) { + remember(state, guestbookMembers) { val s = state ?: return@remember emptyList() val authority = s.authority - val pubkeys = (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers()).map { it.lowercase() }.distinct() + val pubkeys = + (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers) + .map { it.lowercase() } + .distinct() pubkeys .map { RosterEntry(it, ConcordMembership.of(authority, it)) } .sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt index ef6c32edb1..74c1219214 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelFilterAssembler.kt @@ -78,11 +78,11 @@ class ConcordChannelSubAssembler( // kind-1059 filters lives in the shared planner. val subs = ArrayList() subs += ConcordSubscriptionPlanner.controlPlaneSubs(entries) - // NOTE: the CORD-06 Guestbook + next-rekey planes are deliberately NOT folded into this - // shared control+channel REQ. Naming those extra stream keys here starved the whole - // subscription on relays that gate (or close) a REQ on NIP-42 stream-key AUTH, so control - // stopped folding and channels went empty. They'll return in their own isolated - // subscription; keeping the core chat path byte-for-byte what it was before CORD-06. + // The Guestbook (membership) + next-epoch base-rekey planes. Their stream keys derive from + // the entry alone, so they AUTH on the initial connection; and since the relay now + // re-authenticates on an `auth-required` CLOSED, naming them here no longer starves the + // control/channel REQ the way it did before that fix. + subs += ConcordSubscriptionPlanner.auxiliaryPlaneSubs(entries) for (entry in entries) { val state = account.concordSessions diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6339b6d884..6cbfaf4116 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -320,6 +320,10 @@ %1$d channel %1$d channels + + %1$d member + %1$d members + Create Invite people Invite link diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index a3b2574ed3..da1be042ed 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -104,6 +104,22 @@ class ConcordCommunitySession( /** The live Guestbook membership set (self-signed joins minus later leaves). */ val members: StateFlow> = _members + /** + * The community's full membership (lowercase hex): everyone who announced on the Guestbook, + * plus the owner and every role-holder (who are members whether or not they posted a join), + * minus the banned. Best-effort — a member who joined without a Guestbook motion and holds no + * role is invisible (key possession leaves no trace), so this is a floor, not a census. + */ + fun allMembers(): Set { + val s = _state.value + val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet() + val banned = s?.authority?.bannedMembers().orEmpty() + return (_members.value + roster) - banned + } + + /** The size of [allMembers] — the community's true (best-effort) member count. */ + fun memberCount(): Int = allMembers().size + /** The current Chat Plane addresses to subscribe to, one per folded channel. */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys.toSet() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 8a6fcb2065..1e10c0a7d5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -107,18 +107,24 @@ class ConcordSessionManager( /** * The stream secret keys that must answer a NIP-42 AUTH challenge from [relay]: - * every plane (control + folded channels) of every joined community whose relays - * include [relay]. Concord relays serve a plane's kind-1059 wraps only to a - * connection authenticated as that stream key, so the relay-auth layer signs a - * kind-22242 with each of these (locally, never the user's signer) — without them - * the connection is authed only as the user, the plane REQ is refused, and no - * channel or message ever loads. + * every plane of every joined community whose relays include [relay] — the Control + * Plane + folded channels ([ConcordCommunitySession.streamKeys]) plus the Guestbook + * and next-epoch base-rekey planes ([ConcordCommunitySession.auxStreamKeys]). Concord + * relays serve a plane's kind-1059 wraps only to a connection authenticated as that + * stream key, so the relay-auth layer signs a kind-22242 with each of these (locally, + * never the user's signer) — without them the plane REQ is refused. Since the relay + * re-authenticates on an `auth-required` CLOSED, a key revealed only after the Control + * Plane folds (a channel) is picked up on the retry; the aux keys derive from the entry + * alone, so they authenticate on the initial connection. */ fun streamAuthSecretsFor(relay: NormalizedRelayUrl): List { val out = ArrayList() for (session in registry.sessions()) { val relays = session.entry.relays.mapNotNullTo(HashSet()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (relay in relays) session.streamKeys().forEach { out.add(it.secretKey) } + if (relay in relays) { + session.streamKeys().forEach { out.add(it.secretKey) } + session.auxStreamKeys().forEach { out.add(it.secretKey) } + } } return out } From 8e30349cbdc165b35536094e304e7563fb2e7225 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 18:53:58 -0400 Subject: [PATCH 087/115] fix(concord): open a minichat reply in its thread, and resolve the plane from the reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a minichat reply (a kind-1111 CommentEvent) in notifications routed to the whole channel: routeFor(note) matched the reply's Concord / relay-group / public-chat gatherer and opened the channel, swallowing the thread it was replying in. routeFor now recognizes a chat-context CommentEvent as a minichat reply and routes to Route.ChatMinichat(rootId) — rootId being the reply's NIP-22 root (the parent message), for all three chat contexts. For Concord, the parent message may not be cached (a cold reply notification), and MinichatScreen used to resolve the plane only from the root note's gatherer — so an unloaded parent could never pick a relay. The reply itself arrived over the channel plane, so its ConcordChannel gatherer carries the community/channel: Route.ChatMinichat now also threads concordCommunityId/concordChannelId from the reply, and MinichatScreen uses them to mount the plane subscription + this channel's backward-history pager, paging until the parent message loads. Its whole kind-1111 thread then projects normally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/routes/RouteMaker.kt | 32 +++++++++++++ .../amethyst/ui/navigation/routes/Routes.kt | 7 ++- .../loggedIn/chats/minichat/MinichatScreen.kt | 47 +++++++++++++++++-- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index d49d68af89..744a2d5ba4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -606,6 +606,8 @@ fun BuildNavigation( composableFromEndArgs { MinichatScreen( rootId = it.rootId, + concordCommunityId = it.concordCommunityId, + concordChannelId = it.concordChannelId, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index aa713474c4..8d1f8d7af2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -66,10 +66,42 @@ import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +/** + * A minichat reply — a kind-1111 [CommentEvent] posted into a chat message's thread — should open + * that message's minichat ([Route.ChatMinichat]), not the whole channel it lives in. Regular chat + * messages are kind 9 / 42, so a [CommentEvent] in a *chat context* is always a thread reply. We + * gate on the chat context (the reply is attached to a Concord / relay-group / public-chat gatherer, + * or its root message is) precisely so a generic NIP-22 comment on an article or note keeps its own + * thread route and isn't mistaken for a minichat. Returns null when it isn't a chat-context comment. + */ +fun minichatRouteFor(note: Note): Route? { + val comment = note.event as? CommentEvent ?: return null + val rootId = comment.rootEventIds().firstOrNull() ?: return null + + // Prefer the reply's own Concord channel (the reply arrived over that plane, so its gatherer + // always carries the community/channel), else the root note's if it happens to be loaded. Passing + // these lets the minichat screen resolve the plane + relays even when the parent isn't cached. + val concord = + note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + ?: LocalCache.getNoteIfExists(rootId)?.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concord != null) { + return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId) + } + + val inChatContext = note.isInChatGatherer() || LocalCache.getNoteIfExists(rootId)?.isInChatGatherer() == true + return if (inChatContext) Route.ChatMinichat(rootId) else null +} + +private fun Note.isInChatGatherer(): Boolean = inGatherers?.any { it is ConcordChannel || it is RelayGroupChannel || it is PublicChatChannel } == true + fun routeFor( note: Note, loggedIn: Account, ): Route? { + // A minichat reply opens the message's thread, not the whole channel it belongs to. Must run + // before the channel-gatherer shortcuts below, which would otherwise swallow it into the channel. + minichatRouteFor(note)?.let { return it } + // Marmot group messages should navigate to the group chat val marmotGroup = note.inGatherers?.firstNotNullOfOrNull { it as? MarmotGroupChatroom } if (marmotGroup != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6c11434f12..5d2df46e35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -709,9 +709,14 @@ sealed class Route { // The "minichat" of a chat message: its kind-1111 thread replies, opened from the message and // rendered as a chat-within-a-chat. Keyed by the root message id; the screen resolves the chat - // context (Concord channel, public chat, relay group) from the note's gatherer. + // context (Concord channel, public chat, relay group) from the note's gatherer. When opened from + // a Concord reply whose parent message may not be cached, [concordCommunityId]/[concordChannelId] + // carry the plane context (taken from the reply's channel), so the screen can still subscribe the + // plane and backfill the parent — without them, a reply to an unloaded parent can't pick the relay. @Serializable data class ChatMinichat( val rootId: HexKey, + val concordCommunityId: String? = null, + val concordChannelId: String? = null, ) : Route() @Serializable data class ChannelMetadataEdit( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 405505252b..80d4fb0bc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -54,11 +54,14 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.stringRes @@ -67,6 +70,9 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -85,17 +91,29 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @Composable fun MinichatScreen( rootId: String, + concordCommunityId: String? = null, + concordChannelId: String? = null, accountViewModel: AccountViewModel, nav: INav, ) { val rootNote = remember(rootId) { LocalCache.getOrCreateNote(rootId) } - val isConcord = remember(rootNote) { rootNote.inGatherers?.any { it is ConcordChannel } == true } - // Datasource: keep the thread replies flowing no matter the entry point. Concord replies - // arrive over the channel plane; public-chat (NIP-28/NIP-29) replies over a relay REQ for - // this message's kind-1111 children (a no-op for Concord). + // Resolve the Concord channel from the (loaded) root note's gatherer, else from the ids threaded + // in by the reply that opened this minichat. The latter is what lets a reply-to-an-unloaded-parent + // still pick the plane + relays: the reply arrived over the channel plane, so its channel id is known. + val concordChannel = remember(rootNote) { rootNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + val communityId = concordCommunityId ?: concordChannel?.channelId?.communityId + val channelId = concordChannelId ?: concordChannel?.channelId?.channelId + val isConcord = communityId != null && channelId != null + + // Datasource: keep the thread replies flowing no matter the entry point. Concord replies arrive + // over the channel plane, so mount the plane subscription plus this channel's backward-history + // pager and page it until the parent message loads (see [ConcordMinichatBackfillUntilRoot]); + // public-chat (NIP-28/NIP-29) replies come over a relay REQ for this message's kind-1111 children. if (isConcord) { ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + ConcordChannelHistorySubscription(communityId!!, channelId!!, accountViewModel.dataSources().concordChannelHistory, accountViewModel) + ConcordMinichatBackfillUntilRoot(rootNote, accountViewModel.dataSources().concordChannelHistory.history) } EventFinderFilterAssemblerSubscription(rootNote, accountViewModel) @@ -195,3 +213,24 @@ fun MinichatScreen( } } } + +/** + * Pages the Concord channel's backward history until the minichat's parent message loads (or the + * relays are exhausted). Reaching a minichat from a reply notification can land on a parent that + * isn't in the live tail; the reply itself pinned the channel context, so we can walk the plane + * history back to fetch the parent — after which its whole kind-1111 thread projects normally. The + * `!loading` gate serializes pages; once the root's event is present, or nothing is left, it latches off. + */ +@Composable +private fun ConcordMinichatBackfillUntilRoot( + rootNote: Note, + history: ConcordChannelHistorySubAssembler, +) { + LaunchedEffect(rootNote, history) { + combine(history.loadingMore, history.status) { loading, status -> + rootNote.event == null && !loading && !status.exhausted + }.distinctUntilChanged() + .filter { it } + .collect { history.advanceAll() } + } +} From bed5d93a12f15494962a2d104007b960be26a751 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:48:28 +0000 Subject: [PATCH 088/115] feat(concord): typing indicators (CORD kind 23311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish a kind-23311 typing heartbeat as an ephemeral (21059) stream wrap on the channel plane, throttled to once every few seconds while composing. The session folds inbound heartbeats into a per-channel typing map with an 8s freshness window (never echoing the local user), and the channel screen renders a slim "X is typing…" line above the composer with a ticker so a typist who stops silently fades out. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 17 ++++ .../ui/screen/loggedIn/AccountViewModel.kt | 8 ++ .../concord/ConcordChannelScreen.kt | 97 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 3 + .../commons/actions/ConcordActions.kt | 16 +++ .../actions/ConcordSubscriptionPlanner.kt | 4 +- .../model/concord/ConcordCommunitySession.kt | 49 +++++++++- .../actions/ConcordSubscriptionPlannerTest.kt | 5 +- .../concord/cord03Channels/ChannelChat.kt | 26 +++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 20 ++++ 10 files changed, 240 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 89d8286c8d..e208855eeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2137,6 +2137,23 @@ class Account( return true } + /** + * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at + * most every few seconds while composing. Not folded locally (we never show our own typing); + * ephemeral, so relays broadcast but never store it. + */ + suspend fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) { + if (!isWriteable()) return + val entry = concordSessions.sessionFor(communityId)?.entry ?: return + val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isNotEmpty()) client.publish(wrap, relays) + } + /** Instant local echo (the session folds it back as a Note) + publish to the community relays. */ private fun publishConcordWrap( entry: ConcordCommunityListEntry, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 773bc34f1e..b9c3c80530 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -641,6 +641,14 @@ class AccountViewModel( account.importConcordCommunities() } + /** Publish an ephemeral typing heartbeat to a Concord channel (throttled by the caller). */ + fun sendConcordTyping( + communityId: String, + channelIdHex: String, + ) = viewModelScope.launch(Dispatchers.IO) { + account.sendConcordTyping(communityId, channelIdHex) + } + @Immutable data class NoteComposeReportState( val isPostHidden: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 9d497cd44f..2cecc809d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import android.widget.Toast import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -38,15 +39,21 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -55,6 +62,7 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachMarkers import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachSentinels import com.vitorpamplona.amethyst.commons.ui.feeds.RelayReachState import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField @@ -82,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -206,6 +215,8 @@ fun ConcordChannelScreen( ) } + ConcordTypingIndicator(communityId, channelId, accountViewModel) + if (channel.canPost()) { Spacer(modifier = DoubleVertSpacer) ConcordMessageComposer( @@ -258,6 +269,75 @@ private fun ConcordBackfillHistoryToWindow( } } +/** Republish a typing heartbeat at most this often (seconds) while composing. */ +private const val TYPING_HEARTBEAT_SECS = 4L + +/** + * A slim "X is typing…" line above the composer, driven by the session's ephemeral + * typing heartbeats (kind 23311). A ~2s ticker re-applies the freshness window so a + * typist who stops silently fades out even without a new ingest. + */ +@Composable +private fun ConcordTypingIndicator( + communityId: String, + channelId: String, + accountViewModel: AccountViewModel, +) { + val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + val typingMap by session.typing.collectAsStateWithLifecycle() + + var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) } + LaunchedEffect(session) { + while (true) { + delay(2000L) + nowSecs = TimeUtils.now() + } + } + + val typers = + remember(typingMap, channelId, nowSecs) { + (typingMap[channelId] ?: emptyMap()) + .filterValues { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS } + .keys + .sorted() + } + + if (typers.isEmpty()) return + + val label = + when (typers.size) { + 1 -> stringRes(R.string.concord_typing_one, rememberTypistName(typers[0], accountViewModel)) + 2 -> + stringRes( + R.string.concord_typing_two, + rememberTypistName(typers[0], accountViewModel), + rememberTypistName(typers[1], accountViewModel), + ) + else -> stringRes(R.string.concord_typing_many) + } + + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 2.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontStyle = FontStyle.Italic, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + } +} + +/** Resolves [hex] to its best display name, reactively, falling back to a short hex. */ +@Composable +private fun rememberTypistName( + hex: String, + accountViewModel: AccountViewModel, +): String { + val user = remember(hex) { accountViewModel.checkGetOrCreateUser(hex) } ?: return remember(hex) { hex.take(8) } + val info by observeUserInfo(user, accountViewModel) + return info?.info?.bestName() ?: remember(user) { user.pubkeyDisplayHex() } +} + private fun reachState(p: RelayPagingProgress): RelayReachState = when { p.done -> RelayReachState.DONE @@ -282,6 +362,9 @@ private fun ConcordMessageComposer( val canPost by remember { derivedStateOf { newMessageModel.canPost() } } val context = LocalContext.current + // Throttle typing heartbeats to at most one every few seconds while the field is non-empty. + val lastTypingSecs = remember(newMessageModel.channelId) { longArrayOf(0L) } + DisposableEffect(newMessageModel.channelId) { onDispose { newMessageModel.userSuggestions?.reset() } } @@ -306,7 +389,19 @@ private fun ConcordMessageComposer( ThinPaddingTextField( state = newMessageModel.message, - onTextChanged = { newMessageModel.onMessageChanged() }, + onTextChanged = { + newMessageModel.onMessageChanged() + val community = newMessageModel.communityId + val channel = newMessageModel.channelId + val now = TimeUtils.now() + if (community != null && channel != null && + newMessageModel.message.text.isNotEmpty() && + now - lastTypingSecs[0] >= TYPING_HEARTBEAT_SECS + ) { + lastTypingSecs[0] = now + accountViewModel.sendConcordTyping(community, channel) + } + }, inputTransformation = MentionPreservingInputTransformation, outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6cbfaf4116..4eb178ccc4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -311,6 +311,9 @@ You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. Show all channels + %1$s is typing… + %1$s and %2$s are typing… + Several people are typing… New Concord Channel Name About (optional) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index a9161c62cb..f44b0c427f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -202,6 +202,22 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an **ephemeral** typing heartbeat wrap (kind-23311 rumor, kind-21059 wrap) + * on the [channel] plane. Relays broadcast but never store it; publish every few + * seconds while the user is composing. + */ + suspend fun buildChannelTyping( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event { + val rumor = ChannelChat.typing(authorSigner.pubKey, channelId, epoch, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true, ephemeral = true, createdAt = createdAt) + } + /** * Opens the channel [wraps], keeps the kind-9 messages correctly bound to * [channelId]/[epoch], and returns them oldest-first (createdAt, then id). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index bd9fe78212..71c50cd625 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -135,7 +135,9 @@ object ConcordSubscriptionPlanner { relay = relay, filter = Filter( - kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + // Stored plane wraps (1059) plus ephemeral ones (21059) — the latter carry the + // live-only typing heartbeats a relay broadcasts but never stores. + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL), authors = authors.toList(), since = since?.get(relay)?.time, ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index fbdf9d634a..96fe78d233 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -25,11 +25,14 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -127,6 +130,16 @@ class ConcordCommunitySession( /** The live Guestbook membership set (self-signed joins minus later leaves). */ val members: StateFlow> = _members + // channelIdHex -> (other member pubkey -> createdAt secs of their latest typing heartbeat). + private val typingByChannel = HashMap>() + private val _typing = MutableStateFlow>>(emptyMap()) + + /** + * The latest typing-heartbeat time (createdAt secs) per channel per *other* member (kind 23311, + * CORD-03). The UI applies its own freshness window and shows those still typing. + */ + val typing: StateFlow>> = _typing + /** * The community's full membership (lowercase hex): everyone who announced on the Guestbook, * plus the owner and every role-holder (who are members whether or not they posted a join), @@ -227,7 +240,14 @@ class ConcordCommunitySession( } else -> { val channelRef = lock.withLock { channelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE - val (channelIdHex, _) = channelRef + val (channelIdHex, key) = channelRef + // An ephemeral wrap on a channel plane is a transient signal (typing) — fold it into + // the typing state, never into the stored message buffer or the Note sink. The typing + // UI collects the [typing] StateFlow directly, so this needs no structural revision bump. + if (wrap.kind == ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) { + ingestTyping(wrap, channelIdHex, key) + return ConcordIngestOutcome.NON_STRUCTURAL + } lock.withLock { channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) } @@ -239,6 +259,28 @@ class ConcordCommunitySession( } } + private fun ingestTyping( + wrap: Event, + channelIdHex: HexKey, + key: GroupKey, + ) { + val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return + if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, entry.rootEpoch)) return + val who = rumor.pubKey.lowercase() + if (who == myPubKey.lowercase()) return // never show my own typing back to me + val now = TimeUtils.now() + val snapshot = + lock.withLock { + val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() } + val prev = perChannel[who] + if (prev == null || rumor.createdAt > prev) perChannel[who] = rumor.createdAt + perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS } + if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex) + typingByChannel.mapValues { it.value.toMap() } + } + _typing.value = snapshot + } + private fun refold() { val wraps = lock.withLock { controlWraps.values.toList() } val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) @@ -270,4 +312,9 @@ class ConcordCommunitySession( onRumor(entry.id, channelIdHex, rumor) } } + + companion object { + /** A typing heartbeat is considered current for this many seconds after it's seen. */ + const val TYPING_STALE_SECS = 8L + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index 82598e6b17..bb43bb818e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -92,11 +92,12 @@ class ConcordSubscriptionPlannerTest { val subs = ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)) val relay = RelayUrlNormalizer.normalizeOrNull("wss://r.example")!! - // One kind-1059 filter for the single relay, carrying the derived since. + // One filter for the single relay, carrying the derived since. Live subscriptions ask for + // both the stored wrap (1059) and the ephemeral wrap (21059) that carries typing heartbeats. val filters = ConcordSubscriptionPlanner.relayBasedFilters(subs, mutableMapOf(relay to MutableTime(1234L)))!! assertEquals(1, filters.size) assertEquals(relay, filters[0].relay) - assertEquals(listOf(1059), filters[0].filter.kinds) + assertEquals(listOf(1059, 21059), filters[0].filter.kinds) assertEquals(1234L, filters[0].filter.since) assertTrue(filters[0].filter.authors!!.contains(community.controlPlane.publicKeyHex)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 419469d4b1..a2477da820 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -152,6 +152,32 @@ object ChannelChat { content = content, ) + /** Chat Plane typing indicator (CORD-03): a transient "user is composing" heartbeat. */ + const val KIND_TYPING = 23311 + + /** + * Builds an unsigned kind-23311 typing heartbeat bound to [channelId]/[epoch]. + * Empty content; wrap it as an **ephemeral** stream event (kind 21059) so relays + * broadcast but never store it. Republish every few seconds while composing; a + * receiver shows the author as typing until the heartbeat goes stale. + */ + fun typing( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + createdAt: Long, + ): Event = + RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = KIND_TYPING, + tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), + content = "", + ) + + /** True when [rumor] is a typing heartbeat (kind 23311). */ + fun isTyping(rumor: Event): Boolean = rumor.kind == KIND_TYPING + /** The channel id a Chat Plane [rumor] is bound to, or null if unbound. */ fun channelOf(rumor: Event): HexKey? = rumor.tags.concordChannel() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index e2c554bf64..632f53a99b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -99,6 +99,26 @@ class ChannelChatEndToEndTest { assertTrue(ChannelChat.isBoundTo(thread, channelIdHex, 0L)) } + @Test + fun typingHeartbeatIsAnEphemeralWrapReadableByAnotherMember() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + + val rumor = ChannelChat.typing(alice.pubKey, channelIdHex, rootEpoch, createdAt = 1_700_000_000L) + val wrap = ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = true, ephemeral = true) + + // The wrap is the ephemeral kind so relays broadcast but never store it. + assertEquals(ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL, wrap.kind) + + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertTrue(ChannelChat.isTyping(opened.rumor)) + assertEquals(ChannelChat.KIND_TYPING, opened.rumor.kind) + assertEquals(alice.pubKey, opened.author) + assertTrue(ChannelChat.isBoundTo(opened.rumor, channelIdHex, rootEpoch)) + assertFalse(ChannelChat.isTyping(ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "hi", 1L))) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest { From dab89bd7819992924c6b261453ac6b3d91886268 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:48:39 +0000 Subject: [PATCH 089/115] fix(chat): don't re-render the minichat root as each reply's reply-to preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every reply in a minichat is rooted at the note pinned at the top, so each reply row was redundantly rendering that same root as an inner-quote reply preview. Add a LocalSuppressReplyToNoteId composition local that RenderReplyRow honors, and have the minichat provide its root id around the list — so a reply whose parent IS the root shows no preview, while a reply to another reply still shows its (distinct) parent. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../loggedIn/chats/feed/ChatMessageCompose.kt | 11 ++++- .../loggedIn/chats/minichat/MinichatScreen.kt | 48 +++++++++++-------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt index f8044fad67..cc777dacc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageCompose.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -450,6 +451,13 @@ private fun MinichatReplyChip( } } +/** + * Id of a note whose reply-to preview should be suppressed on a message row. Set by a + * thread view that already pins that note at the top (the minichat pins its root), so each + * reply doesn't redundantly re-render the same parent as an inner quote. Null everywhere else. + */ +val LocalSuppressReplyToNoteId = compositionLocalOf { null } + @Composable fun RenderReplyRow( note: Note, @@ -462,7 +470,8 @@ fun RenderReplyRow( onScrollToNote: ((Note) -> Unit)? = null, ) { val replyTo = note.replyTo?.lastOrNull() - if (!innerQuote && replyTo != null && !isCitedInContent(note, replyTo)) { + val suppressId = LocalSuppressReplyToNoteId.current + if (!innerQuote && replyTo != null && replyTo.idHex != suppressId && !isCitedInContent(note, replyTo)) { RenderReply(note, bgColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft, onScrollToNote) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 80d4fb0bc0..9b9e886855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription @@ -148,27 +150,31 @@ fun MinichatScreen( }, ) { padding -> Column(Modifier.fillMaxHeight().padding(padding)) { - LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f, true)) { - item("root") { - ChatroomMessageCompose( - baseNote = rootNote, - routeForLastRead = null, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {}, - onWantsToEditDraft = {}, - ) - HorizontalDivider() - } - items(replies, key = { it.idHex }) { reply -> - ChatroomMessageCompose( - baseNote = reply, - routeForLastRead = null, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {}, - onWantsToEditDraft = {}, - ) + // Every reply here is rooted at [rootNote], which is already pinned at the top — so + // suppress the redundant reply-to-root preview each reply would otherwise render. + CompositionLocalProvider(LocalSuppressReplyToNoteId provides rootId) { + LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().weight(1f, true)) { + item("root") { + ChatroomMessageCompose( + baseNote = rootNote, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + HorizontalDivider() + } + items(replies, key = { it.idHex }) { reply -> + ChatroomMessageCompose( + baseNote = reply, + routeForLastRead = null, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } } } From b17e4ddb861816227c29df35cd718ae89b596426 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:53:11 +0000 Subject: [PATCH 090/115] fix(concord): persist the hub's per-community expand state across navigation The chevron tri-state (closed / unread peek / all) lived in a plain remember, so opening a channel and returning to the hub reset every community to closed. Move it to rememberSaveable with a Saver so the expansion each user set is restored when the hub re-enters composition. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordHomeScreen.kt | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 517d14fdf4..cbc0022be3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -47,6 +47,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -112,7 +114,8 @@ fun ConcordHomeScreen( // Per-community expansion, cycled on tap: absent = CLOSED → UNREAD (peek only the channels with // new messages) → OPEN (all channels) → CLOSED. Multi-open, so several can be expanded at once. - var expandStates by remember { mutableStateOf(emptyMap()) } + // rememberSaveable so the chevron states survive opening a channel and coming back to the hub. + var expandStates by rememberSaveable(stateSaver = ExpandStatesSaver) { mutableStateOf(emptyMap()) } Scaffold( topBar = { @@ -516,3 +519,19 @@ private enum class ChannelExpand { /** Every channel, plus the banner hero. */ OPEN, } + +/** + * Saver for the per-community expand map so the chevron states survive navigation (open a channel, + * come back). Serializes to an `ArrayList` of `"communityId=MODE"` — community ids are hex, + * so `=` never collides. + */ +private val ExpandStatesSaver = + Saver, ArrayList>( + save = { map -> ArrayList(map.map { "${it.key}=${it.value.name}" }) }, + restore = { list -> + list.associate { + val sep = it.lastIndexOf('=') + it.substring(0, sep) to ChannelExpand.valueOf(it.substring(sep + 1)) + } + }, + ) From e734d6400c245688cd0754ea7e474d14f4affd4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:44:56 +0000 Subject: [PATCH 091/115] feat(concord): send & receive encrypted image messages (Armada-compatible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord channel messages can now carry images, wire-identical to Soapbox Armada's `encryptAttachments`: a normal channel-bound kind-9 whose ciphertext URL is appended to the content and annotated by a NIP-92 `imeta` tag with `encryption-algorithm aes-gcm`, hex `decryption-key`/`decryption-nonce`, and the plaintext `ox` hash (no `x`). The blob is AES-256-GCM ciphertext on Blossom, so the media host and relays only ever see encrypted bytes — the community's E2E guarantee holds. Reuses the NIP-17 encrypted-media stack end to end: quartz's imeta tag vocab and IMetaTagBuilder to build/parse the tag (ChannelChat.imageMessage / encryptedImageImeta / encryptedImagesOf), the shared UploadOrchestrator encrypted upload + ChatFileUploadDialog picker on the send side, and the OkHttp EncryptedBlobInterceptor keyCache on the receive side — registering each attachment's cipher (keyed by URL) lets the normal feed renderer display the decrypted image with no shared-render changes. Encryption is mandatory (no toggle, and a missing cipher fails closed). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 44 +++++++ .../concord/ConcordChannelScreen.kt | 113 ++++++++++++++++++ .../send/ConcordNewMessageViewModel.kt | 15 +++ amethyst/src/main/res/values/strings.xml | 1 + .../commons/actions/ConcordActions.kt | 18 +++ .../concord/cord03Channels/ChannelChat.kt | 110 +++++++++++++++++ .../cord03Channels/ChannelChatEndToEndTest.kt | 50 ++++++++ 7 files changed, 351 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b61da3d44a..b5f477560a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.actions.ConcordActions @@ -148,6 +149,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity @@ -330,6 +332,7 @@ import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -495,9 +498,28 @@ class Account( ?.value ?.authority if (authority?.isBanned(rumor.pubKey) == true) return + registerConcordEncryptedImages(rumor) cache.consumeConcordRumor(communityId, channelIdHex, rumor) } + /** + * Register any encrypted image attachments on a Concord message ([ChannelChat.encryptedImagesOf]) + * so the shared media pipeline can display them: the ciphertext blob's AES-256-GCM key/nonce go + * into [com.vitorpamplona.amethyst.AppModules.keyCache], and the OkHttp EncryptedBlobInterceptor + * decrypts the blob transparently on fetch (keyed by URL) — the same path NIP-17 encrypted media + * uses. Runs for both inbound wraps and our own local echo, so a sent image renders immediately. + */ + private fun registerConcordEncryptedImages(rumor: Event) { + val images = ChannelChat.encryptedImagesOf(rumor) + if (images.isEmpty()) return + val keyCache = Amethyst.instance.keyCache + images.forEach { img -> + if (img.algo == AESGCM.NAME) { + keyCache.add(img.url, AESGCM(img.key, img.nonce), img.mimeType) + } + } + } + /** * Copies each folded community's metadata (name/icon, channel flags, this account's * membership) onto its [ConcordChannel] objects in the cache, and drops messages from @@ -2067,6 +2089,28 @@ class Account( return true } + /** + * Send a channel message carrying encrypted image attachments ([imetas], built by the composer + * from the encrypted upload) — Armada's `encryptAttachments` shape. The ciphertext URLs are + * appended to [text] and each rides as a NIP-92 `imeta` with `aes-gcm` decryption params. With no + * attachments this is just a plain [sendConcordChannelMessage]. + */ + suspend fun sendConcordChannelImageMessage( + communityId: String, + channelIdHex: String, + text: String, + imetas: List, + ): Boolean { + if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) + 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 wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now()) + publishConcordWrap(entry, wrap) + return true + } + /** * Post [text] into [rootNote]'s minichat — a kind-1111 thread reply rooted at that * message. Resolves the chat context from the note's gatherer; today it drives the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 2cecc809d1..0121df89f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.mutableLongStateOf 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.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -65,6 +66,8 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -72,11 +75,15 @@ import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSugge import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.formatHistoryReachDate +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.SuccessfulUploads import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistorySubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.send.ConcordNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dal.ChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ReplyModeToggle import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton @@ -87,10 +94,13 @@ import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightChat import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayPagingProgress import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -369,6 +379,21 @@ private fun ConcordMessageComposer( onDispose { newMessageModel.userSuggestions?.reset() } } + // Encrypted image attachments: a picked image opens this dialog, which encrypts + uploads via the + // shared NIP-17 pipeline and sends an Armada-shaped image message on the channel plane. + newMessageModel.uploadState?.let { uploadState -> + uploadState.multiOrchestrator?.let { + ConcordFileUploadDialog( + newMessageModel = newMessageModel, + state = uploadState, + accountViewModel = accountViewModel, + nav = nav, + onUpload = { onMessageSent() }, + onCancel = uploadState::reset, + ) + } + } + newMessageModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newMessageModel.clearReply() } ReplyModeToggle( @@ -402,6 +427,9 @@ private fun ConcordMessageComposer( accountViewModel.sendConcordTyping(community, channel) } }, + onContentReceived = { uri, mimeType -> + newMessageModel.pickedMedia(persistentListOf(SelectedMedia(uri, mimeType))) + }, inputTransformation = MentionPreservingInputTransformation, outputTransformation = UrlUserTagOutputTransformation(MaterialTheme.colorScheme.primary), modifier = Modifier.fillMaxWidth(), @@ -412,6 +440,19 @@ private fun ConcordMessageComposer( color = MaterialTheme.colorScheme.placeholderText, ) }, + leadingIcon = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) { + SelectFromGallery( + isUploading = false, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier, + onImageChosen = newMessageModel::pickedMedia, + ) + } + }, trailingIcon = { ThinSendButton( isActive = canPost, @@ -437,3 +478,75 @@ private fun ConcordMessageComposer( ) } } + +/** + * The picked-image confirmation dialog for a Concord channel. Reuses the shared NIP-17 upload + * pipeline: it always encrypts (no encryption toggle is shown, and [SuccessfulUploads.toConcordImeta] + * fails closed if a cipher is somehow absent), uploads the ciphertext, then sends one Armada-shaped + * image message ([Account.sendConcordChannelImageMessage]) carrying every attachment's `imeta`. + */ +@Composable +private fun ConcordFileUploadDialog( + newMessageModel: ConcordNewMessageViewModel, + state: ChatFileUploadState, + accountViewModel: AccountViewModel, + nav: INav, + onUpload: suspend () -> Unit, + onCancel: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + ChatFileUploadDialog( + state = state, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_send_image_title)) }, + upload = { + scope.launch(Dispatchers.IO) { + val community = newMessageModel.communityId + val channel = newMessageModel.channelId + if (community == null || channel == null) return@launch + + ChatFileUploader(accountViewModel.account).justUploadNIP17( + viewState = state, + onError = { title, message -> + scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + onEncryptedUploadError = { title, message -> + scope.launch(Dispatchers.Main) { Toast.makeText(context, "$title: $message", Toast.LENGTH_LONG).show() } + }, + context = context, + onceUploaded = { uploads -> + val imetas = uploads.mapNotNull { it.toConcordImeta() } + if (imetas.isNotEmpty()) { + accountViewModel.account.sendConcordChannelImageMessage(community, channel, "", imetas) + } + onUpload() + }, + ) + + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) + accountViewModel.account.settings.changeStripLocationOnUpload(state.stripMetadata) + } + }, + onCancel = onCancel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +/** + * Turns an encrypted upload into the Armada-shaped `imeta` (via [ChannelChat.encryptedImageImeta]). + * Returns null when the upload carried no cipher — so a non-encrypted blob is never sent as a Concord + * image (fails closed, protecting the community's end-to-end guarantee). + */ +private fun SuccessfulUploads.toConcordImeta(): IMetaTag? { + val cipher = cipher ?: return null + return ChannelChat.encryptedImageImeta( + url = result.url, + mimeType = result.mimeTypeBeforeEncryption, + dim = result.fileHeader.dim?.toString(), + blurhash = result.fileHeader.blurHash?.blurhash, + cipher = cipher, + originalHash = result.hashBeforeEncryption, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index bc22137a99..039319003d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -24,7 +24,9 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode @@ -32,10 +34,13 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ChatFileUploadState import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.collections.immutable.ImmutableList /** * Composition state for the Concord channel message field, mirroring the other @@ -62,6 +67,10 @@ open class ConcordNewMessageViewModel : ViewModel() { var userSuggestions: UserSuggestionState? = null + // Encrypted image attachments ride through the shared NIP-17 upload pipeline; a picked image + // opens the upload dialog, which encrypts + uploads and sends an Armada-shaped image message. + var uploadState by mutableStateOf(null) + open fun init(accountVM: AccountViewModel) { this.accountViewModel = accountVM this.account = accountVM.account @@ -74,6 +83,12 @@ open class ConcordNewMessageViewModel : ViewModel() { // Rank people who have posted in this channel first. priorityPubkeys = { channelAuthors() }, ) + + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) + } + + fun pickedMedia(media: ImmutableList) { + uploadState?.load(media) } private fun channelAuthors(): Set { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index ed79270878..0a01231dad 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -312,6 +312,7 @@ You haven\'t joined any Concord Channels yet. Create one, or open an invite link. No channels yet. Show all channels + Send image %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index f44b0c427f..dce6d62b3a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent /** One decrypted, verified Concord channel message projected for display. */ @@ -160,6 +161,23 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal channel message wrap carrying one or more encrypted image [imetas] + * (Armada `encryptAttachments` shape) to publish on the [channel] plane. + */ + suspend fun buildChannelImageMessage( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + createdAt: Long, + ): Event { + val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal inline quote-reply wrap (kind-9 message quoting [parent] via `q`) on the [channel] plane. */ suspend fun buildChannelInlineReply( authorSigner: NostrSigner, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index a2477da820..e878436ae4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -24,11 +24,20 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey +import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.tags.OriginalHashTag import com.vitorpamplona.quartz.nipC7Chats.ChatEvent +import com.vitorpamplona.quartz.utils.ciphers.AESGCM /** * Chat Plane message binding (CORD-03). @@ -152,6 +161,90 @@ object ChannelChat { content = content, ) + /** + * Builds an unsigned kind-9 message carrying one or more **encrypted image** attachments + * ([imetas]), wire-identical to Soapbox Armada's `encryptAttachments` path so images interop + * across Concord clients. Each attachment's ciphertext URL is appended to the text content (the + * ones not already present), exactly as Armada assembles it, and each rides as a NIP-92 `imeta` + * tag ([encryptedImageImeta]). The message is still a normal channel-bound kind-9, so the shared + * feed renders it and the binding is enforced like any other Chat Plane rumor. + */ + fun imageMessage( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + text: String, + imetas: List, + createdAt: Long, + ): Event { + val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } + val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") + return message( + authorPubKey = authorPubKey, + channelId = channelId, + epoch = epoch, + text = finalText, + createdAt = createdAt, + extraTags = imetas.map { it.toTagArray() }.toTypedArray(), + ) + } + + /** + * Builds the encrypted-image `imeta` tag Armada's `ChatComposer` emits with `encryptAttachments`: + * `url` (ciphertext blob), `m` (plaintext mime), `dim`, `blurhash`, plus `encryption-algorithm` + * (`aes-gcm`), `decryption-key`, `decryption-nonce` (hex), and `ox` (the *plaintext* SHA-256 for + * integrity). Deliberately omits `x` (a ciphertext hash) to match Armada exactly. + */ + fun encryptedImageImeta( + url: String, + mimeType: String?, + dim: String?, + blurhash: String?, + cipher: AESGCM, + originalHash: String?, + ): IMetaTag = + IMetaTagBuilder(url) + .apply { + mimeType?.let { add("m", it) } + dim?.let { add("dim", it) } + blurhash?.let { add("blurhash", it) } + add(EncryptionAlgo.TAG_NAME, cipher.name()) + add(EncryptionKey.TAG_NAME, cipher.keyBytes.toHexKey()) + add(EncryptionNonce.TAG_NAME, cipher.nonce.toHexKey()) + originalHash?.let { add(OriginalHashTag.TAG_NAME, it) } + }.build() + + /** + * Parses every **encrypted image** attachment ([ConcordImageAttachment]) carried on [rumor] as an + * `imeta` tag with the `aes-gcm` `decryption-key`/`decryption-nonce` fields. A plaintext imeta + * (no encryption fields) is ignored here — it renders through the normal media path. + */ + fun encryptedImagesOf(rumor: Event): List = + rumor.tags + .mapNotNull { if (it.size >= 2 && it[0] == IMetaTag.TAG_NAME) IMetaTag.parse(it) else null } + .flatten() + .mapNotNull { it.toEncryptedAttachmentOrNull() } + + private fun IMetaTag.prop(key: String): String? = properties[key]?.firstOrNull()?.takeIf { it.isNotEmpty() } + + private fun IMetaTag.toEncryptedAttachmentOrNull(): ConcordImageAttachment? { + val key = prop(EncryptionKey.TAG_NAME) ?: return null + val nonce = prop(EncryptionNonce.TAG_NAME) ?: return null + val algo = prop(EncryptionAlgo.TAG_NAME) ?: return null + val keyBytes = runCatching { key.hexToByteArray() }.getOrNull() ?: return null + val nonceBytes = runCatching { nonce.hexToByteArray() }.getOrNull() ?: return null + return ConcordImageAttachment( + url = url, + mimeType = prop("m"), + dim = prop("dim"), + blurhash = prop("blurhash"), + algo = algo, + key = keyBytes, + nonce = nonceBytes, + originalHash = prop(OriginalHashTag.TAG_NAME), + ) + } + /** Chat Plane typing indicator (CORD-03): a transient "user is composing" heartbeat. */ const val KIND_TYPING = 23311 @@ -194,3 +287,20 @@ object ChannelChat { epoch: Long, ): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch) } + +/** + * A decrypted-pointer to an **encrypted image** attached to a Concord chat message (CORD-03), parsed + * from a NIP-92 `imeta` tag ([ChannelChat.encryptedImagesOf]). The [url] blob is AES-256-GCM + * ciphertext on a media host; fetch it, decrypt with [key]/[nonce], and verify the plaintext SHA-256 + * equals [originalHash] before displaying. Mirrors Soapbox Armada's encrypted attachment for interop. + */ +class ConcordImageAttachment( + val url: String, + val mimeType: String?, + val dim: String?, + val blurhash: String?, + val algo: String, + val key: ByteArray, + val nonce: ByteArray, + val originalHash: String?, +) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt index 632f53a99b..0c371e1749 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatEndToEndTest.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.utils.ciphers.AESGCM import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -119,6 +120,55 @@ class ChannelChatEndToEndTest { assertFalse(ChannelChat.isTyping(ChannelChat.message(alice.pubKey, channelIdHex, rootEpoch, "hi", 1L))) } + @Test + fun encryptedImageMessageMatchesArmadaWireFormatAndRoundTrips() { + val author = KeyPair().pubKey.toHexKey() + val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 }) + val url = "https://blossom.example/ciphertext.bin" + val ox = "aa".repeat(32) + + val imeta = + ChannelChat.encryptedImageImeta( + url = url, + mimeType = "image/jpeg", + dim = "800x600", + blurhash = "LKO2", + cipher = cipher, + originalHash = ox, + ) + val msg = ChannelChat.imageMessage(author, channelIdHex, 0L, "look", listOf(imeta), createdAt = 5L) + + // Still a channel-bound kind-9; the ciphertext url is appended to content (Armada assembly). + assertEquals(9, msg.kind) + assertTrue(ChannelChat.isBoundTo(msg, channelIdHex, 0L)) + assertEquals("look\n$url", msg.content) + + // The imeta tag carries exactly Armada's fields: aes-gcm + hex key/nonce + ox, and NO `x`. + val imetaTag = msg.tags.first { it[0] == "imeta" } + assertTrue(imetaTag.contains("url $url")) + assertTrue(imetaTag.contains("m image/jpeg")) + assertTrue(imetaTag.contains("dim 800x600")) + assertTrue(imetaTag.contains("encryption-algorithm aes-gcm")) + assertTrue(imetaTag.contains("decryption-key ${ByteArray(32) { 0x11 }.toHexKey()}")) + assertTrue(imetaTag.contains("decryption-nonce ${ByteArray(16) { 0x22 }.toHexKey()}")) + assertTrue(imetaTag.contains("ox $ox")) + assertTrue(imetaTag.none { it.startsWith("x ") }) + + // Receiver parses the attachment back with the same key/nonce for decryption. + val parsed = ChannelChat.encryptedImagesOf(msg) + assertEquals(1, parsed.size) + val att = parsed.first() + assertEquals(url, att.url) + assertEquals("image/jpeg", att.mimeType) + assertEquals("aes-gcm", att.algo) + assertEquals(ox, att.originalHash) + assertTrue(att.key.contentEquals(ByteArray(32) { 0x11 })) + assertTrue(att.nonce.contentEquals(ByteArray(16) { 0x22 })) + + // A plaintext message has no encrypted attachments. + assertTrue(ChannelChat.encryptedImagesOf(ChannelChat.message(author, channelIdHex, 0L, "hi", 1L)).isEmpty()) + } + @Test fun nonMembersCannotDeriveThePlane() = runTest { From 64e0fa2a918a50e3f1c0a0c909f7d20226169cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:08:20 +0000 Subject: [PATCH 092/115] fix(concord): restore the Messages server/community chip's highlight PR #3559 muted the shared HeaderPill (correct for NoteCompose's PoW/OTS/location markers), but that also flattened the Messages tab's server/community chip, which is a first-class navigation entry point and should stand out. Give RelayNameChip back its own secondaryContainer highlight instead of the muted pill, and hard-cap the community name at 20 chars so a long title can't crowd the row. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../chats/rooms/ChatroomHeaderCompose.kt | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 428255dfcb..0b005d751e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -20,14 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -455,9 +461,10 @@ private fun ConcordRoomCompose( channel.communityName?.let { communityName -> Spacer(Modifier.width(6.dp)) // The chip names the parent community and, when tapped, opens that community's - // channel list — the "chip that opens the Concord Channel" entry point. + // channel list — the "chip that opens the Concord Channel" entry point. Cap the + // name so a long community title can't crowd out the channel name on the row. RelayNameChip( - label = communityName, + label = communityName.ellipsize(20), onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, ) } @@ -560,19 +567,46 @@ private fun ConcordServerRoomCompose( ) } -/** A small tappable chip naming the relay a channel is hosted on. */ +/** + * A tappable chip naming the server/community a Messages row belongs to. Unlike the muted + * note-header [HeaderPill] (PoW/OTS/location markers), this one is a first-class navigation entry + * point, so it keeps the stronger `secondaryContainer` highlight. + */ @Composable private fun RelayNameChip( label: String, onClick: () -> Unit, ) { - HeaderPill( - symbol = MaterialSymbols.Dns, - text = label, - onClick = onClick, - ) + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Dns, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(11.dp), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } +/** Hard-caps [this] to [max] characters, appending an ellipsis when it was longer. */ +private fun String.ellipsize(max: Int): String = if (length > max) take(max).trimEnd() + "…" else this + @Composable private fun ChannelTitleWithLabelInfo( channelName: String, From 24c2cfedc62ac03ddb091dd13922ec84e2360e07 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:17:13 +0000 Subject: [PATCH 093/115] fix(concord): reply from Notifications/feed goes through the channel plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routeReplyTo special-cased Marmot groups but had no Concord case, so replying to a Concord message outside a chat screen (e.g. from Notifications) fell through to a public kind-1111 comment — leaking the private rumor id onto public relays and producing a reply that doesn't bind to the channel and no member would see. Route a Concord-gathered note's reply into its minichat (its thread root for a kind-1111, the message itself for a kind-9), whose composer sends the wrapped kind-1111 on the channel plane. (Reactions already route through reactToConcordMessage; zaps are forced PRIVATE via isPrivateRumor since a Concord rumor is unsigned.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/navigation/routes/RouteMaker.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index 8d1f8d7af2..f91e05a525 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -344,6 +344,17 @@ fun routeReplyTo( return Route.MarmotGroupChat(marmotGroup.nostrGroupId, replyId = note.idHex) } + // Concord messages are end-to-end encrypted: a reply must go through the channel plane (a sealed + // kind-1111), never a public kind-1111 — which would leak the private rumor id onto public relays + // and wouldn't bind to the channel. Route the reply into the message's minichat, whose composer + // sends the wrapped reply. For a thread reply (kind-1111) reply into the same flat thread (its + // root); for a top-level message (kind-9) the message itself is the thread root. + val concord = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + if (concord != null) { + val rootId = (note.event as? CommentEvent)?.rootEventIds()?.firstOrNull() ?: note.idHex + return Route.ChatMinichat(rootId, concord.channelId.communityId, concord.channelId.channelId) + } + val noteEvent = note.event return when (noteEvent) { is ChannelMessageEvent -> { From 6f63b331198587739a662ec6d835e513d99e79ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:17:15 +0000 Subject: [PATCH 094/115] feat(concord): show the community pill on Concord notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the Messages row's highlighted community chip into a shared ConcordCommunityPill (secondaryContainer, group icon, 20-char cap) and render it in NoteCompose's header markers for any Concord-gathered note, so a Concord message surfaced in Notifications names its community and taps through to the channel list — the same chip, wherever the message appears. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/note/NoteCompose.kt | 13 ++++ .../concord/ConcordCommunityPill.kt | 78 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 11 +-- 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b1810aac12..940f01cd13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -58,6 +58,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill @@ -75,6 +76,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout import com.vitorpamplona.amethyst.ui.layouts.NoteComposeLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.DisplayZapSplits @@ -200,6 +202,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.PodcastTrailerListItem import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay @@ -1907,6 +1910,16 @@ fun FirstUserInfoRow( PrivateRumorMark(accountViewModel) } + // A Concord message (e.g. surfaced in Notifications) names its parent community with the same + // highlighted chip the Messages row uses, tapping through to that community's channel list. + val concordChannel = remember(baseNote) { baseNote.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + concordChannel?.communityName?.let { communityName -> + ConcordCommunityPill( + communityName = communityName, + onClick = { nav.nav(Route.ConcordServer(concordChannel.channelId.communityId)) }, + ) + } + if (isPinned) { PinnedMark() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt new file mode 100644 index 0000000000..45cd285ceb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt @@ -0,0 +1,78 @@ +/* + * 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.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols + +/** + * A tappable chip naming the Concord community a message belongs to. Unlike the muted note-header + * markers (PoW/OTS/location), this is a first-class navigation entry point, so it keeps a strong + * `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Concord + * message reads the same wherever it surfaces. The name is hard-capped so a long title can't crowd + * the row. + */ +@Composable +fun ConcordCommunityPill( + communityName: String, + onClick: () -> Unit, + maxChars: Int = 20, +) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.clickable(onClick = onClick), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = MaterialSymbols.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(11.dp), + ) + Text( + text = if (communityName.length > maxChars) communityName.take(maxChars).trimEnd() + "…" else communityName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 0b005d751e..f83f92f634 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ConcordServerRoomNote @@ -461,10 +462,9 @@ private fun ConcordRoomCompose( channel.communityName?.let { communityName -> Spacer(Modifier.width(6.dp)) // The chip names the parent community and, when tapped, opens that community's - // channel list — the "chip that opens the Concord Channel" entry point. Cap the - // name so a long community title can't crowd out the channel name on the row. - RelayNameChip( - label = communityName.ellipsize(20), + // channel list — the "chip that opens the Concord Channel" entry point. + ConcordCommunityPill( + communityName = communityName, onClick = { nav.nav(Route.ConcordServer(channel.channelId.communityId)) }, ) } @@ -604,9 +604,6 @@ private fun RelayNameChip( } } -/** Hard-caps [this] to [max] characters, appending an ellipsis when it was longer. */ -private fun String.ellipsize(max: Int): String = if (length > max) take(max).trimEnd() + "…" else this - @Composable private fun ChannelTitleWithLabelInfo( channelName: String, From 8060dc0bb1c884cb6e60f439e714b77f651a080f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 16:15:14 -0400 Subject: [PATCH 095/115] fix(concord): fold refounded community control plane for fresh joiners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a Refounding (CORD-06 §3), compaction re-wraps each entity's head verbatim, so a head edited past genesis still carries an `ep`/prev citing an edition in the prior epoch that a fresh joiner never fetches. EditionFold.foldEntity required a genesis edition (prevHash == null) and returned null otherwise, so a fresh login folded the whole Control Plane to nothing: no community icon, no name, no edited channels (while Armada, which implements the CORD-04 §1 fresh-joiner rule, showed them all). Anchor at the lowest-version edition when no genesis is present and walk up from there. Safe: Amethyst always re-folds the whole buffer from scratch (no persistent floor), so it is structurally always a fresh joiner; authority is validated on top by AuthorityResolver, so an unrooted forgery is still dropped; and a genuine mid-chain gap still stops at the intact prefix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../quartz/concord/cord04Roles/EditionFold.kt | 21 +++- .../concord/cord04Roles/ControlEditionTest.kt | 10 +- .../concord/cord04Roles/EditionFoldTest.kt | 105 ++++++++++++++++++ .../cord06Rekey/ConcordRefoundingTest.kt | 71 ++++++++++++ 4 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 99602208e1..3b8e3ff2ea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -28,8 +28,18 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * * Rules enforced here: * - **Genesis anchoring** — a chain starts at the lowest-version edition with no - * `ep` (prev hash). Without a genesis, the entity has no accepted head (we - * hold rather than trust an unrooted later edition). + * `ep` (prev hash). + * - **Refounding fallback (fresh joiner)** — when no genesis is present, anchor + * at the lowest-version edition available and accept it as the baseline. After + * a Refounding (CORD-06 §3) the compacted head still carries the `ep` it had + * before compaction, citing an edition in the *prior* epoch that a fresh joiner + * never fetches — so a dangling `prev` is the norm, not corruption, and CORD-04 + * §1 ("Folding across a Refounding") requires the joiner to take that head as + * its baseline. The signature + owner-rooted authority check (applied by + * [com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver] on top of this + * structural fold) is the whole test, so an unrooted forgery is still dropped + * there. Amethyst always re-folds the whole buffer from scratch, so it is + * structurally always a fresh joiner; it holds no prior chain to fail closed on. * - **Intact chain / no downgrades** — the head advances to `version + 1` only * when that edition's `ep` cites the current head's [ControlEdition.hash]. * Lower or non-chaining versions are ignored. @@ -60,11 +70,16 @@ object EditionFold { val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) - // Genesis: lowest version with no prev hash. Prefer the tie-break winner. + // Anchor at the genesis (lowest version with no prev hash), preferring the + // tie-break winner. When no genesis is present — the compacted head of a + // Refounded community carries a prev citing the prior epoch — a fresh joiner + // anchors at the lowest-version edition it does hold and accepts it as the + // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. var head = editions .filter { it.prevHash == null } .minWithOrNull(compareBy({ it.version }, { it.rumorId })) + ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) ?: return null // Walk the chain upward while the next version chains from the current head. diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt index 85e1551f44..16ca3ddc93 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -148,9 +148,15 @@ class ControlEditionTest { assertEquals("aaa", EditionFold.foldEntity(listOf(b, a))?.rumorId) } + /** + * A lone edition with a dangling `prev` and no genesis is the compacted head of a Refounded + * community (CORD-06 §3): a fresh joiner never holds the prior epoch it chains onto, so the + * head is accepted as the baseline rather than dropped (CORD-04 §1). Dropping it was the bug + * that hid a refounded community's icon, name, and edited channels. See [EditionFoldTest]. + */ @Test - fun foldWithoutGenesisReturnsNull() { + fun foldWithoutGenesisAcceptsCompactedHead() { val v1 = edition(1, ByteArray(32) { 0x05 }, """{"name":"x"}""", "id1") - assertNull(EditionFold.foldEntity(listOf(v1))) + assertEquals("id1", EditionFold.foldEntity(listOf(v1))?.rumorId) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt new file mode 100644 index 0000000000..60ba6d58f3 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFoldTest.kt @@ -0,0 +1,105 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EditionFoldTest { + private val author = KeyPair().pubKey.toHexKey() + private val eid = ByteArray(32) { 0xAB.toByte() } + + private fun edition( + version: Long, + prevHash: ByteArray?, + content: String, + rumorId: String = "id-v$version", + ) = ControlEdition( + entityKind = ControlEntityKind.CHANNEL, + entityId = eid, + version = version, + prevHash = prevHash, + authorityCitation = null, + content = content, + author = author, + rumorId = rumorId, + createdAt = 1_700_000_000L + version, + ) + + /** A normal chain (v0 genesis → v1 → v2) folds to the highest intact version. */ + @Test + fun foldsIntactChainToHead() { + val v0 = edition(0, null, "genesis") + val v1 = edition(1, v0.hash, "one") + val v2 = edition(2, v1.hash, "two") + + val head = EditionFold.foldEntity(listOf(v2, v0, v1)) + assertEquals(2, head?.version) + assertEquals("two", head?.content) + } + + /** + * The Refounding case (CORD-06 §3): a fresh joiner holds only the compacted head, whose + * `prev` cites the prior epoch it never fetched. With no genesis present, the head is + * accepted as the baseline rather than dropped — the bug that hid a refounded community's + * icon, name, and edited channels. + */ + @Test + fun acceptsDanglingCompactedHeadWhenNoGenesis() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + + val head = EditionFold.foldEntity(listOf(danglingHead)) + assertEquals(5, head?.version) + assertEquals("compacted-head", head?.content) + } + + /** A dangling head plus post-refounding edits chains forward from the accepted baseline. */ + @Test + fun advancesFromDanglingHeadAsNewEditionsArrive() { + val danglingHead = edition(5, ByteArray(32) { 0x99.toByte() }, "compacted-head") + val v6 = edition(6, danglingHead.hash, "post-refound edit") + + val head = EditionFold.foldEntity(listOf(v6, danglingHead)) + assertEquals(6, head?.version) + assertEquals("post-refound edit", head?.content) + } + + /** A genuine mid-chain gap still fails closed at the intact prefix — no silent jump past the hole. */ + @Test + fun stopsAtGapWhenGenesisPresent() { + val v0 = edition(0, null, "genesis") + // v1 is missing; v2 cites a hash we don't hold, so it can't chain onto v0. + val v2 = edition(2, ByteArray(32) { 0x77.toByte() }, "orphan") + + val head = EditionFold.foldEntity(listOf(v2, v0)) + assertEquals(0, head?.version) + assertEquals("genesis", head?.content) + } + + /** No editions at all → no head. */ + @Test + fun emptyFoldsToNull() { + assertNull(EditionFold.foldEntity(emptyList())) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index d5811c0501..fa919e9b14 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -22,6 +22,13 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -126,6 +133,70 @@ class ConcordRefoundingTest { } } + @Test + fun freshJoinerSeesEntitiesEditedAfterGenesisThenRefounded() = + runTest { + // A real, long-lived community edits its metadata (adds an icon) and renames + // #general AFTER genesis, THEN gets refounded. Those edits produce version-1 + // editions whose `ep` chains onto the genesis edition. Compaction keeps only + // each entity's head — so the re-wrapped heads still carry a `prev` pointing at + // the (now absent) prior-epoch edition. A fresh joiner fetching only the + // compacted heads must still see them (CORD-04 §1 "Folding across a Refounding", + // CORD-06 §3): the signature + current-authority check is the whole test. + val community = ConcordCommunityFactory.create(owner, "NosFabrica", now) + val communityId = community.communityId + val control = community.controlPlane + + val genesisMeta = community.genesisEditions.first { it.entityKind == ControlEntityKind.METADATA } + val genesisChannel = community.genesisEditions.first { it.entityKind == ControlEntityKind.CHANNEL } + + val icon = ImagePointer(url = "https://media/icon.enc", key = "1a".repeat(32), nonce = "2b".repeat(16), hash = "3c".repeat(32)) + + // v1 metadata: add the icon, chained onto genesis. + val metaV1Json = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "NosFabrica", icon = icon)) + val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, 1, genesisMeta.hash, metaV1Json, now + 1) + val metaV1Wrap = ConcordStreamEnvelope.wrap(metaV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + // v1 channel: rename #general, chained onto genesis. + val chanV1Json = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), ChannelEntity(name = "lobby", private = false)) + val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, 1, genesisChannel.hash, chanV1Json, now + 1) + val chanV1Wrap = ConcordStreamEnvelope.wrap(chanV1Rumor, control, owner, encrypted = false, createdAt = now + 1) + + val priorWraps = community.genesisWraps + metaV1Wrap + chanV1Wrap + + val build = + ConcordRefounding.build( + rotatorSigner = owner, + communityId = communityId, + priorRoot = community.communityRoot, + newRoot = newRoot, + rootEpoch = community.rootEpoch, + priorControlWraps = priorWraps, + priorControlKey = control, + recipientsXOnly = listOf(alice.pubKey), + createdAt = now, + ) + + val newControl = ConcordKeyDerivation.controlPlaneKey(newRoot, communityId, build.newEpoch) + val editions = + build.controlWraps.mapNotNull { wrap -> + ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) } + } + val folded = ConcordCommunityState.fold(editions, owner.pubKey) + + // A fresh joiner MUST see the compacted heads — name, icon, and the renamed channel. + assertEquals("NosFabrica", folded.metadata?.name, "fresh joiner lost the community name after refounding") + assertEquals(icon, folded.metadata?.icon, "fresh joiner lost the community icon after refounding") + assertEquals( + "lobby", + folded.channels.values + .firstOrNull() + ?.definition + ?.name, + "fresh joiner lost the (edited) channel after refounding", + ) + } + @Test fun wrongPriorRootFailsContinuity() = runTest { From a4d9087187e61fcc29688ea76aa1b44fc33d709b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 16:15:28 -0400 Subject: [PATCH 096/115] fix(concord): include the 32-zero id in the invite bundle key (CORD-05) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORD-05 Appendix A.1 says the HKDF `id` is always present — 32 bytes, all-zero for a label with no meaningful id — and A.6 lists `concord/invite-key` with `id = 0…0`. inviteBundleKey built its info with no id at all, so it derived a different bundle key than the reference client (Armada) and NIP-44 decryption of an Armada-minted invite bundle failed with "Invalid Mac" — the join aborted with "no valid bundle for this link". amy round-tripped with itself (same wrong key both sides), so its own tests passed and the divergence went unnoticed. Verified by decrypting a real Armada invite bundle: the no-id key fails the MAC; the zero-id key yields a valid CommunityInvite JSON. Pass ByteArray(32) like the sibling banlist/dissolved derivations already do. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/crypto/ConcordKeyDerivation.kt | 11 +++++++---- .../concord/crypto/ConcordKeyDerivationTest.kt | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index 14d694b0cd..b59827aadd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -219,11 +219,14 @@ object ConcordKeyDerivation { /** * Derives the invite bundle decryption key from a link's 16-byte unlock - * [token] (CORD-05): `hkdf32(token, "concord/invite-key" ‖ 0x00)`. The token - * lives only in the URL fragment, so a server that sees the naddr can never - * open the bundle. + * [token] (CORD-05): `hkdf32(token, "concord/invite-key" ‖ 0x00 ‖ ZERO32)`. Per + * Appendix A.1 the `id` is *always present*, 32 bytes, all-zeroes for a label + * with no meaningful id (A.6 lists `concord/invite-key` with `id = 0…0`), so the + * 32 zero bytes must be fed into the HKDF `info` — omitting them yields a key that + * fails to open a reference-client (Armada) bundle. The token lives only in the URL + * fragment, so a server that sees the naddr can never open the bundle. */ - fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY)) + fun inviteBundleKey(token: ByteArray): ByteArray = hkdf32(token, buildInfo(ConcordLabels.INVITE_KEY, ByteArray(32))) // ---- CORD-06 rekey addresses & commitment --------------------------------- diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt index 617cab0f58..557c0515ee 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivationTest.kt @@ -66,6 +66,24 @@ class ConcordKeyDerivationTest { assertEquals(ConcordLabels.CONTROL.encodeToByteArray().size + 1 + 8, info.size) } + // ---- CORD-05 invite bundle key -------------------------------------------- + + /** + * The invite-key HKDF `info` MUST carry the 32-byte all-zero id (CORD-05 A.1: "the id is + * always present, 32 bytes, all-zeroes where a label has no meaningful id" — A.6 lists + * `concord/invite-key` with `id = 0…0`). Omitting it derived a key that could not open a + * reference-client (Armada) bundle — confirmed by decrypting a live Soapbox invite: only the + * zero-id key produced valid JSON. This pins the id in so the derivation can't silently regress. + */ + @Test + fun inviteBundleKeyIncludesZeroId() { + val token = ByteArray(16) { it.toByte() } + val zeroId = ConcordKeyDerivation.hkdf32(token, ConcordKeyDerivation.buildInfo(ConcordLabels.INVITE_KEY, ByteArray(32))) + val idLess = ConcordKeyDerivation.hkdf32(token, ConcordKeyDerivation.buildInfo(ConcordLabels.INVITE_KEY)) + assertContentEquals(zeroId, ConcordKeyDerivation.inviteBundleKey(token)) + assertNotEquals(zeroId.toHexKey(), idLess.toHexKey()) + } + // ---- groupKey ------------------------------------------------------------- @Test From 122403baf4e01323642b06254ebb7d3dd5d799d3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 16:32:54 -0400 Subject: [PATCH 097/115] feat(cli): AUTH as the Concord plane stream key so amy can read planes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them only to a connection authenticated AS the plane's derived stream key — the member is neither the wrap's author (the stream key) nor its recipient (a throwaway ephemeral key), so an account AUTH is refused and every plane REQ came back empty (no channels, no messages), even though the community folded its name. Mirror the app's per-plane AUTH (60475c10c0) in the CLI: - Context.registerConcordStreamKeys(relays, secrets) records the derived control/channel stream secrets, scoped to the community's relays. - The RelayAuthenticator provider now signs one kind-22242 per registered stream key alongside the account AUTH — locally, from the raw derived key (NostrSignerSync), never the account, so no user identity is exposed. - The concord channels/read/send verbs register their control + channel stream keys before draining/publishing. - drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay pending instead of terminal, so the post-auth subscription re-fire delivers the events rather than the one-shot drain returning empty. The concord verbs opt in; all other drains are unchanged. Verified end-to-end against the live Soapbox community (relay.ditto.pub / relay.dreamith.to): `amy concord channels` now folds the name + 9 channels consistently and `amy concord read` returns messages. `channels` also emits the folded icon/banner/description pointers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/cli/Context.kt | 70 +++++++++++++++++-- .../cli/commands/ConcordChannelCommands.kt | 19 ++++- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 82cc60df9a..1c79588135 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter @@ -50,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -58,13 +61,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent @@ -92,6 +98,7 @@ import okhttp3.Dispatcher import okhttp3.OkHttpClient import okhttp3.Request import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -247,6 +254,41 @@ class Context( startCap = System.getenv("AMY_RELAY_SUB_CAP")?.toIntOrNull()?.coerceIn(1, 100) ?: 16, ).also { client.addConnectionListener(it) } + /** + * Concord plane stream-key AUTH (CORD-01 §4b). Concord relays gate a plane's + * kind-1059 wraps behind NIP-42 and serve them only to a connection authenticated + * AS the plane's derived *stream key* — the member is neither the wrap's author + * (the stream key) nor its recipient (a throwaway ephemeral key), so an account + * AUTH is refused. `amy concord` verbs register their control + channel stream + * secrets here (scoped to the community's relays, the same scope the plane REQ + * uses) before draining, and [relayAuth] answers a challenge from one of those + * relays with one kind-22242 per stream key — signed locally from the raw derived + * key, never the account, so no user identity is exposed. + */ + private val concordStreamSecrets = ConcurrentHashMap>() + private val concordStreamSigners = ConcurrentHashMap() + + /** Registers raw 32-byte Concord stream [secrets] to answer NIP-42 challenges from [relays]. */ + fun registerConcordStreamKeys( + relays: Set, + secrets: List, + ) { + if (relays.isEmpty() || secrets.isEmpty()) return + val hexes = secrets.map { it.toHexKey() } + for (relay in relays) concordStreamSecrets.getOrPut(relay) { ConcurrentHashMap.newKeySet() }.addAll(hexes) + } + + /** Signs one kind-22242 per Concord stream key registered for [relay] (empty if none). */ + private fun signConcordStreamAuths( + relay: NormalizedRelayUrl, + template: EventTemplate, + ): List = + concordStreamSecrets[relay].orEmpty().mapNotNull { hex -> + runCatching { + concordStreamSigners.getOrPut(hex) { NostrSignerSync(KeyPair(privKey = hex.hexToByteArray())) }.sign(template) + }.getOrNull() + } + /** * NIP-42 responder: answers a relay's AUTH challenge by signing with the * account key, so auth-gated relays serve our reads instead of CLOSing the @@ -254,16 +296,20 @@ class Context( * Only a local key auto-signs — a remote bunker signer is skipped, since a * per-relay remote round-trip during a crawl would stall it (and signing an * auth event with any key still unlocks relays that just want *some* auth). + * Any Concord stream keys registered via [registerConcordStreamKeys] for the + * challenging relay are signed alongside the account AUTH. */ private val relayAuth: RelayAuthenticator = RelayAuthenticator( client = client, - signWithAllLoggedInUsers = { _, template, _ -> - if (signer is NostrSignerInternal) { - runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } - } else { - emptyList() - } + signWithAllLoggedInUsers = { relay, template, _ -> + val accountAuth = + if (signer is NostrSignerInternal) { + runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } + } else { + emptyList() + } + accountAuth + signConcordStreamAuths(relay, template) }, ) @@ -585,12 +631,21 @@ class Context( * proven-dead relays from future routing instead of paying the full * [timeoutMs] on them again. Slow-but-connected relays are NOT reported — * only hard connect failures, so a temporarily-busy relay isn't discarded. + * + * With [pendingOnAuthRequired], a relay that refuses the REQ with an + * `auth-required` CLOSED is kept pending rather than treated as terminal: the + * NIP-42 responder answers the challenge and the client re-fires this same + * subscription (`syncFilters`), so the post-auth events are collected instead of + * returning empty. If auth never satisfies it, the relay simply falls through to + * the [timeoutMs]. Needed for Concord planes, whose kind-1059 wraps are served + * only to a connection authenticated as the derived stream key. */ suspend fun drain( filters: Map>, timeoutMs: Long = 8_000, diagnoseSlow: Boolean = false, deadOut: MutableMap? = null, + pendingOnAuthRequired: Boolean = false, ): List> { if (filters.isEmpty()) return emptyList() val eventChannel = Channel>(UNLIMITED) @@ -623,6 +678,9 @@ class Context( relay: NormalizedRelayUrl, forFilters: List?, ) { + // Keep the relay pending on an auth-required refusal: the authenticator answers the + // challenge and re-fires this subscription, so the post-auth events still arrive. + if (pendingOnAuthRequired && MachineReadablePrefix.parse(message) == MachineReadablePrefix.AUTH_REQUIRED) return doneChannel.trySend(relay to "closed:$message") } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index b45b222a82..7c0577670b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -47,6 +47,9 @@ object ConcordChannelCommands { Output.emit( mapOf( "name" to state.metadata?.name, + "description" to state.metadata?.description, + "icon" to state.metadata?.icon?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, + "banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, "channels" to state.channels.values.map { mapOf("id" to it.channelIdHex, "name" to it.definition.name, "voice" to it.definition.voice, "private" to it.definition.private) @@ -72,7 +75,10 @@ object ConcordChannelCommands { val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) - val acked = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)).filterValues { it }.keys + val relays = ConcordCommands.relaysFor(ctx, sc) + // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val acked = ctx.publish(wrap, relays).filterValues { it }.keys Output.emit(mapOf("event_id" to wrap.id, "channel" to channelId, "published_to" to acked.map { it.url })) return 0 } @@ -92,7 +98,10 @@ object ConcordChannelCommands { ctx.prepare() val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) - val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }).map { it.second } + val relays = ConcordCommands.relaysFor(ctx, sc) + // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. + ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(channel.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } val msgs = ConcordActions.channelMessages(wraps, channel, channelId, sc.rootEpoch).takeLast(limit) Output.emit( mapOf( @@ -111,7 +120,11 @@ object ConcordChannelCommands { sc: StoredCommunity, ): ConcordCommunityState { val controlPlane = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) - val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }).map { it.second } + val relays = ConcordCommands.relaysFor(ctx, sc) + // The relays gate the plane's kind-1059 behind NIP-42 as the derived stream key — register + // it so the drain's AUTH challenge is answered as the control plane, not the account. + ctx.registerConcordStreamKeys(relays, listOf(controlPlane.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) } From 92f422be88edb589fcf119f607c1c6aa257838ea Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 17:01:40 -0400 Subject: [PATCH 098/115] =?UTF-8?q?fix(concord):=20authority-gate=20the=20?= =?UTF-8?q?control-plane=20fold=20(CORD-04=20=C2=A71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fold selected each entity's head by STRUCTURAL fold only and never dropped editions from unauthorized signers, so a spoofed edition that structurally supersedes a legit one won — on the live Soapbox community this surfaced a decoy metadata edition ("invalid ... clients should be ignoring this", no icon) over the real owner-authorized one, so no community icon/banner ever rendered. Two stacked wire/authority bugs, both fixed: - RoleEntity.scope was typed String, but the reference client (Armada) writes it as an object ({"kind":"server"} / {"kind":"channel","channel_id":...}) per CORD-04 §2. The type mismatch failed the whole RoleEntity decode, dropping the role — and with it every grant depending on it — so no admin ever resolved. Added RoleScope and retyped the field (no writer set it, so no migration). - AuthorityResolver.resolve now folds each role/grant/banlist CHAIN through authorized editions only, via an owner-rooted fixpoint over the full edition set (not the post-hoc structural heads). A rogue higher-version grant from an unprivileged key is dropped instead of superseding the owner's grant, so the legit authority stands. ConcordCommunityState.fold then gates metadata by MANAGE_METADATA, channels by MANAGE_CHANNELS, the banlist by BAN, and the dissolution tombstone to the owner alone. Verified live end-to-end: `amy concord channels Soapbox` now folds the real metadata (name "Soapbox Community", the blossom icon + banner pointers) and all 9 channels; fetching + AES-256-GCM-decrypting the icon pointer yields a hash-verified PNG. Regression tests cover the object-scoped role and the rogue superseding grant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cord02Community/ConcordCommunityState.kt | 45 +++++-- .../concord/cord04Roles/AuthorityResolver.kt | 122 +++++++++++------- .../concord/cord04Roles/ControlEntities.kt | 17 ++- .../cord04Roles/AuthorityResolverTest.kt | 44 +++++++ 4 files changed, 168 insertions(+), 60 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index daba4d65ec..8f2b7ddd9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold @@ -60,21 +61,46 @@ class ConcordCommunityState( ownerPubKey: String, ): ConcordCommunityState { val heads = EditionFold.fold(editions).values + // Resolve authority from the FULL edition set (not the structural heads): the resolver + // folds each role/grant chain through authorized editions only, so a rogue higher-version + // edition can't supersede a legit one before authority is even judged. + val authority = AuthorityResolver.resolve(editions, ownerPubKey) + // CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is + // owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant + // fixpoint), so gating each managed entity by its required permission BEFORE the + // structural fold filters out spoofed editions — e.g. a decoy metadata genesis minted by + // an unprivileged key — instead of letting a higher-version forgery win the chain. The + // permission check also excludes banned authors (hasPermission is false for a banned npub). + fun editorsWith( + kind: ControlEntityKind, + bit: Int, + ): List = + editions.filter { + it.entityKind == kind && (authority.isOwner(it.author) || authority.hasPermission(it.author, bit)) + } + + // Metadata is one entity (== community id), gated by MANAGE_METADATA. Fold only the + // authorized editions, then take the highest-version head (guarding against strays). val metadata = - heads - .filter { it.entityKind == ControlEntityKind.METADATA } - .maxByOrNull { it.version } // one metadata entity; guard against strays + EditionFold + .fold(editorsWith(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)) + .values + .maxByOrNull { it.version } ?.let { ConcordJson.decodeOrNull(it.content) } + // Channels are gated by MANAGE_CHANNELS. Fold each channel entity from its authorized + // editions only, dropping the tombstoned ones. val channels = LinkedHashMap() - for (e in heads) { - if (e.entityKind != ControlEntityKind.CHANNEL) continue - val def = ConcordJson.decodeOrNull(e.content) ?: continue + for (head in EditionFold.fold(editorsWith(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS)).values) { + val def = ConcordJson.decodeOrNull(head.content) ?: continue if (def.deleted) continue - channels[e.entityIdHex] = ConcordChannel(e.entityIdHex, def) + channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) } + // Role definitions ride along for display; the AuthorityResolver already owner-roots the + // privileged roster via the grant fixpoint, so a role a rogue defines is inert until an + // authorized granter (who must outrank it and hold MANAGE_ROLES) actually hands it out. val roles = HashMap() for (e in heads) { if (e.entityKind != ControlEntityKind.ROLE) continue @@ -83,14 +109,15 @@ class ConcordCommunityState( roles[e.entityIdHex] = r } - val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED } + // Dissolution is owner-only — a rogue tombstone must not appear to kill the community. + val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) } return ConcordCommunityState( ownerPubKey = ownerPubKey.lowercase(), metadata = metadata, channels = channels, roles = roles, - authority = AuthorityResolver.resolve(heads, ownerPubKey), + authority = authority, dissolved = dissolved, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index efefab9211..4a49765fe8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -113,38 +113,24 @@ class AuthorityResolver private constructor( const val OWNER_RANK = 0L fun resolve( - heads: Collection, + editions: Collection, ownerPubKey: String, ): AuthorityResolver { val ownerLower = ownerPubKey.lowercase() - // Roles: keyed by role id (the edition entity id). Drop deleted roles - // and any that illegally claim position 0 (owner-peer). - val roles = HashMap() - for (e in heads) { - if (e.entityKind != ControlEntityKind.ROLE) continue - val r = ConcordJson.decodeOrNull(e.content) ?: continue - if (r.deleted || r.position < 1) continue - roles[e.entityIdHex] = r - } + // Chains grouped by entity: one role chain per role id, one grant chain per member + // coordinate. We fold each chain through AUTHORIZED editions only, so a rogue cannot + // supersede a legit edition by minting a higher version from an unprivileged key + // (CORD-04 §1: "an edition whose signer isn't authorized is dropped"). + val roleChains = editions.filter { it.entityKind == ControlEntityKind.ROLE }.groupBy { it.entityIdHex } + val grantChains = editions.filter { it.entityKind == ControlEntityKind.GRANT }.groupBy { it.entityIdHex } - // Grants: one head per member; remember the signing actor (granter). - val grants = - heads.mapNotNull { e -> - if (e.entityKind != ControlEntityKind.GRANT) return@mapNotNull null - val g = ConcordJson.decodeOrNull(e.content) ?: return@mapNotNull null - ParsedGrant(g.member.lowercase(), g.roleIds, e.author.lowercase()) - } - - // Banlist: heal to the union of every banlist head. - val banned = HashSet() - for (e in heads) { - if (e.entityKind != ControlEntityKind.BANLIST) continue - ConcordJson.decodeBanlist(e.content)?.forEach { banned.add(it.lowercase()) } - } - - val memberRoles = HashMap>() + var roles: Map = emptyMap() + var memberRoles: Map> = emptyMap() + // Authority helpers read the CURRENT (previous-pass) roster, so within a pass a granter's + // rank is judged by the chain already settled behind it — the owner-rooted resolution the + // spec requires ("the fold starts at the owner ... and resolves outward"). fun rankOf(member: String): Long? { if (member == ownerLower) return OWNER_RANK val held = memberRoles[member] ?: return null @@ -157,32 +143,70 @@ class AuthorityResolver private constructor( return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true } } - // Fixpoint: apply grants whose signer outranks every assigned role and - // holds MANAGE_ROLES. Only the owner is empowered a priori, so the - // roster grows strictly outward from the owner. - var changed = true - while (changed) { - changed = false - for (g in grants) { - val granterRank = rankOf(g.granter) ?: continue - if (!holdsManageRoles(g.granter)) continue - val assigned = g.roleIds.filter { roles.containsKey(it) } - if (assigned.any { granterRank >= roles.getValue(it).position }) continue // must strictly outrank each - val newSet = assigned.toSet() - if (memberRoles[g.member] != newSet) { - memberRoles[g.member] = newSet - changed = true - } + // Owner-rooted fixpoint: each pass only ever empowers members reachable from the owner, so + // the roster grows monotonically and settles. Bounded by the edition count as a backstop. + val maxPasses = editions.size + 1 + var pass = 0 + while (pass++ <= maxPasses) { + // Roles: a role edition is authorized when its author is the owner or holds MANAGE_ROLES. + // Fold each role chain through its authorized editions, then keep a live, ranked head. + val newRoles = HashMap() + for ((entity, chain) in roleChains) { + val head = + EditionFold.foldEntity(chain.filter { it.author.lowercase() == ownerLower || holdsManageRoles(it.author.lowercase()) }) + ?: continue + val r = ConcordJson.decodeOrNull(head.content) ?: continue + if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0 + newRoles[entity] = r } + + // Grants: an edition is authorized when its granter is the owner, or holds MANAGE_ROLES + // AND strictly outranks every role it hands out. Fold each member's grant chain through + // its authorized editions so a rogue higher-version grant is dropped, not honored. + val newMemberRoles = HashMap>() + for ((_, chain) in grantChains) { + val head = + EditionFold.foldEntity( + chain.filter { e -> + val granter = e.author.lowercase() + if (granter == ownerLower) return@filter true + if (!holdsManageRoles(granter)) return@filter false + val granterRank = rankOf(granter) ?: return@filter false + val g = ConcordJson.decodeOrNull(e.content) ?: return@filter false + // Must strictly outrank each assigned role that actually exists. + g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true } + }, + ) ?: continue + val g = ConcordJson.decodeOrNull(head.content) ?: continue + newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet() + } + + if (newRoles == roles && newMemberRoles == memberRoles) break + roles = newRoles + memberRoles = newMemberRoles } - return AuthorityResolver(ownerLower, roles, memberRoles, banned) + // The union of a member's roles' permission bits (owner holds every bit). + fun effectivePermissionsOf(member: String): ConcordPermissions { + if (member == ownerLower) return ConcordPermissions.ALL + val held = memberRoles[member] ?: return ConcordPermissions.NONE + var acc = ConcordPermissions.NONE + for (id in held) roles[id]?.let { acc = acc union it.permissionBits() } + return acc + } + + // Banlist: honored only from a signer holding BAN (or the owner), then healed to the head. + val banHead = + EditionFold.foldEntity( + editions.filter { + it.entityKind == ControlEntityKind.BANLIST && + (it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN)) + }, + ) + val banned = HashSet() + banHead?.let { ConcordJson.decodeBanlist(it.content) }?.forEach { banned.add(it.lowercase()) } + + return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned) } } - - private class ParsedGrant( - val member: String, - val roleIds: List, - val granter: String, - ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index e7df8bd3a2..adacb499e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -56,6 +56,19 @@ object ConcordJson { } } +/** + * A Role's scope (CORD-04 §2): server-wide (`{"kind":"server"}`) or restricted to a + * single channel (`{"kind":"channel","channel_id":""}`). It is an **object** on + * the wire, pinned to the Concord v2 reference client — NOT a bare string. Typing it as + * a `String` (the old bug) makes the whole [RoleEntity] fail to decode, which silently + * drops the role, and with it every authority (grant) that depends on it. + */ +@Serializable +class RoleScope( + val kind: String = "server", + @SerialName("channel_id") val channelId: String? = null, +) + /** * A Role's content (CORD-04): a named bundle of permissions at a [position]. * The role's id is the edition's entity id, not a content field. Lower [position] @@ -67,8 +80,8 @@ class RoleEntity( val position: Long = 0, /** u64 permission bitfield as a decimal string. */ val permissions: String = "0", - /** "server"-wide, or a channel id for a channel-scoped role. Null = server. */ - val scope: String? = null, + /** Server-wide, or a single channel — an object (see [RoleScope]). Null = server. */ + val scope: RoleScope? = null, val color: Long = 0, val deleted: Boolean = false, ) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index 0d21a74dd0..def1241c6c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -163,4 +163,48 @@ class AuthorityResolverTest { val r = AuthorityResolver.resolve(heads, owner) assertNull(r.rank(alice)) // both assigned roles are invalid } + + /** + * The reference client (Armada) writes a role's `scope` as an object + * (`{"kind":"server"}`), NOT a bare string. Typing the field as `String` made the whole + * RoleEntity fail to decode, dropping the role and every grant that depended on it — the + * community then had no resolvable admins, so authority-gated metadata/channels vanished. + */ + @Test + fun objectScopedRoleDecodesAndItsGrantResolves() { + val heads = + listOf( + role(adminRole, """{"name":"Admin","position":1,"permissions":"25","scope":{"kind":"server"},"color":0}"""), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertEquals(1L, r.rank(alice)) + assertTrue(r.effectivePermissions(alice).has(BAN)) + } + + /** + * The owner grants alice Admin (v0); an UNAUTHORIZED key mints a higher-version grant on the + * same coordinate stripping her roles. The structural head is the rogue v1, but an edition + * whose signer isn't authorized is dropped (CORD-04 §1) — so the fold must NOT let the rogue + * supersede the owner's grant. alice keeps Admin. (This was the live Soapbox failure.) + */ + @Test + fun rogueHigherVersionGrantCannotSupersedeALegitGrant() { + val grantId = "ab".repeat(32) + val ownerGrant = grant(grantId, alice, listOf(adminRole), granter = owner) // v0, prev null + val rogueV1 = + ControlEdition( + ControlEntityKind.GRANT, + grantId.hexToByteArray(), + 1, + ownerGrant.hash, // chains onto the owner's grant, so it wins the STRUCTURAL fold + null, + """{"member":"$alice","role_ids":[]}""", + carol, // an unauthorized signer + "grant-$grantId-rogue", + 1, + ) + val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner) + assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands + } } From 83c985819f272fa638dfcc0e337bb2cb37c9ac3a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 18:10:21 -0400 Subject: [PATCH 099/115] fix(concord): render the decrypted community icon (local file:// avatar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord community icon is CORD-02 §6 encrypted media: rememberConcordImageModel fetches the ciphertext, AES-256-GCM-decrypts it, and caches the plaintext at a local file:// path, which it hands to the avatar as the model. But RobohashFallbackAsyncImage always wrapped the model in ProfilePictureUrl, and the thumbnail-cache fetcher behind it (ProfilePictureFetcher) delegates a cache-miss to Coil's http-only NetworkFetcher — so the file:// load failed and the row fell back to the robohash, even though the icon had decrypted and cached correctly. Only route remote http(s) pictures through the thumbnail cache; hand local/content URIs straight to Coil's native fetchers, which load them directly. Verified on-device: the Soapbox community icon now renders on the Messages rows and the community header instead of a robohash. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/ui/components/RobohashAsyncImage.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 7e95230683..b4bf3f8c81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -149,7 +149,16 @@ fun RobohashFallbackAsyncImage( val resources = LocalContext.current.resources SubcomposeAsyncImage( - model = ProfilePictureUrl(bridgedModel), + // The thumbnail-cache fetcher behind ProfilePictureUrl delegates to Coil's http-only + // NetworkFetcher, so a LOCAL model (e.g. a decrypted Concord community icon cached at + // file://) would fail there. Route only remote http(s) pictures through the thumbnail + // cache; hand local/content URIs to Coil's native fetchers, which load them directly. + model = + if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) { + ProfilePictureUrl(bridgedModel) + } else { + bridgedModel + }, contentDescription = contentDescription, modifier = modifier, alignment = alignment, From 86e29ed25a8d1861962d6976dfa36ad796211caa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:46:52 +0000 Subject: [PATCH 100/115] fix(concord): surface Concord replies/reactions on the Notifications tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the notification path (datasource + dal + display) for Concord replies and likes that p-tag the user: - Datasource OK: ConcordChannelPreload (mounted always-on in LoggedInPage) keeps every joined community's control + channel planes subscribed app-wide, so the wrapped reply/reaction wraps arrive regardless of tab. (The account-level #p=self notification sub can't see them — the outer wrap p-tag is ephemeral.) - Projection OK: channelRumors filters only by channel/epoch binding, so kind-7 reactions and kind-1111 replies both reach LocalCache via consumeConcordRumor. - DAL had two gaps, now fixed: 1. The follow-scope gate dropped notifications from community members who aren't in my follows (they usually aren't) unless in Global mode. Concord notes now bypass it like Chess/DM/Marmot — the explicit p-tag already scopes to genuine replies/reactions/mentions, so general chatter never leaks. 2. Concord's default reply mode is an INLINE reply (kind-9 ChatEvent), which wasn't in NOTIFICATION_KINDS at all, so inline replies and @-mentions never notified. Added kind-9 (only notifies when it p-tags me). Minichat replies (kind-1111) and likes (kind-7) were already displayable. Also let Concord notes skip the per-kind relevance heuristic (the p-tag is the relevance signal), so a reaction still notifies when my target message hasn't loaded yet to resolve replyTo. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../dal/NotificationFeedFilter.kt | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 124e578ad0..fc4e7bdd8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote @@ -81,6 +82,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -136,6 +138,10 @@ class NotificationFeedFilter( setOf( BadgeAwardEvent.KIND, ChannelMessageEvent.KIND, + // kind-9 chat message (NIP-C7 / Concord): notifies only when it p-tags me — an inline + // reply (Concord's default reply mode) or an @-mention. A minichat reply is a kind-1111 + // CommentEvent below; a plain channel message tags no one and never reaches here. + ChatEvent.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, CommentEvent.KIND, @@ -434,6 +440,12 @@ class NotificationFeedFilter( // Chess events bypass the follow filter — opponents may not be followed val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent + // Concord community messages bypass the follow filter too: a reply/reaction that p-tags me + // in a community I've joined is relevant whether or not I follow that member (fellow members + // usually aren't follows). The p-tag gate below still applies, so only genuine replies / + // reactions / mentions notify — general channel chatter that doesn't tag me never does. + val isConcord = it.inGatherers?.any { g -> g is ConcordChannel } == true + // Global keeps every event that p-tags the user; Selected (and the // follow/list modes) also applies the per-kind relevance heuristics. val isRawGlobal = followList() is TopFilter.Global @@ -447,11 +459,14 @@ class NotificationFeedFilter( // to genuine replies, so unrelated channel chatter never leaks through. return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && - (isChessEvent || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && + (isChessEvent || isConcord || filterParams.isGlobal() || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && (noteEvent?.isTaggedUser(loggedInUserHex) == true || isNotifiablePublicChatReply(it, loggedInUserHex)) && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && - (isRawGlobal || tagsAnEventByUser(it, loggedInUserHex)) + // For a Concord note the explicit p-tag above IS the relevance signal (the reply/reaction/ + // mention targets me directly), so skip the per-kind heuristic — which for a reaction would + // otherwise need my target message already loaded to resolve replyTo. + (isRawGlobal || isConcord || tagsAnEventByUser(it, loggedInUserHex)) } override fun sort(items: Set): List = items.sortedByDefaultFeedOrder() From e8b7ad8f8baa455d83243011b3ed41319f72d9de Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:59:09 +0000 Subject: [PATCH 101/115] fix(concord): tapping a minichat reply opens its thread, not just the channel routeFor sent every Concord-gathered note to the channel screen, so tapping a kind-1111 minichat reply (e.g. from Notifications or the thread view) landed in the channel instead of the reply's thread. Route a Concord CommentEvent through minichatRouteFor (its thread); a top-level message / inline reply / reaction still opens the channel. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../amethyst/ui/navigation/routes/RouteMaker.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index f91e05a525..fb3c985919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -118,11 +118,12 @@ fun routeFor( } // Concord channel content (kind 9 chat, 1111 reply, 7 reaction) lands in LocalCache as a real - // Note attached to its ConcordChannel gatherer. Like the relay-group case above, route to the - // Concord channel screen instead of the generic thread view it would otherwise fall through to. + // Note attached to its ConcordChannel gatherer. Route to the Concord chat instead of the generic + // thread view it would otherwise fall through to: a minichat reply (kind-1111) opens its thread + // ([minichatRouteFor]); a top-level message / reaction opens the channel. val concordChannel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } if (concordChannel != null) { - return routeFor(concordChannel) + return minichatRouteFor(note) ?: routeFor(concordChannel) } val noteEvent = note.event ?: return Route.EventRedirect(note.idHex) From af8a95c44cbb24c3bc9409e0ce32f94ef9a4c960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:11:42 +0000 Subject: [PATCH 102/115] feat(concord): add an Open channel action to the minichat thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord minichat pins its root and backfills it from the channel history, but when the root is still loading (or you aren't a member yet) there was no way out. Add an Open channel action to the top bar for Concord threads, navigating to the full channel (Route.Concord) where the whole timeline loads and membership lives — the correct affordance to click through to the chat room itself. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../screen/loggedIn/chats/minichat/MinichatScreen.kt | 11 +++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 12 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index 9b9e886855..f12aba10f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.LocalSuppressReplyToNoteId @@ -146,6 +147,16 @@ fun MinichatScreen( SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) } }, + actions = { + // For a Concord thread, always offer a jump to the full channel — the "chat room + // itself", where the whole timeline loads and (if you aren't a member yet) you can + // join. This is the way out when the pinned root is still backfilling or unavailable. + if (isConcord) { + IconButton(onClick = { nav.nav(Route.Concord(communityId!!, channelId!!)) }) { + SymbolIcon(symbol = MaterialSymbols.Forum, contentDescription = stringRes(R.string.concord_open_channel)) + } + } + }, ) }, ) { padding -> diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0a01231dad..f7bbe180e6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -313,6 +313,7 @@ No channels yet. Show all channels Send image + Open channel %1$s is typing… %1$s and %2$s are typing… Several people are typing… From d84555a27b496c07f80df821106dc8496f7d1374 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 18:44:54 -0400 Subject: [PATCH 103/115] =?UTF-8?q?fix(concord):=20real=20role=20names=20+?= =?UTF-8?q?=20full=20member=20roster=20(CORD-02=20=C2=A75=20/=20CORD-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two roster gaps against the reference client (Armada): 1. Moderators showed as "Admin" and role definitions were often empty. The displayed roles came from ConcordCommunityState.roles, which was built from the RAW structural fold heads — so a rogue higher-version edition on a role's coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster, and even when present the UI collapsed every role-holder to a single "Admin" badge. Now state.roles comes from the authority-gated resolver (AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen renders each member's actual most-privileged role name (Admin / Moderator / custom). 2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an author seen publishing is observably present, auto-included even if their Join never arrived." The roster only counted Guestbook joiners + the privileged roster, omitting the bulk of members who never post a Join. ConcordCommunitySession now tracks observedAuthors from every decrypted channel message and folds them into allMembers() and the roster. Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles. Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and Moderator (pos 2) instead of []. quartz + commons concord suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordMembersScreen.kt | 46 +++++++++++++------ .../cli/commands/ConcordModCommands.kt | 6 ++- .../model/concord/ConcordCommunitySession.kt | 27 +++++++++-- .../cord02Community/ConcordCommunityState.kt | 15 ++---- .../concord/cord04Roles/AuthorityResolver.kt | 6 +++ 5 files changed, 71 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 4f24c1007c..fba6ddb27b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -94,22 +94,33 @@ fun ConcordMembersScreen( val session = remember(account, communityId, revision) { account.concordSessions.sessionFor(communityId) } val state by (session?.state ?: remember { MutableStateFlow(null) }).collectAsStateWithLifecycle() - // The Guestbook membership (self-signed joins), so plain members show alongside the owner, - // admins and banned — not just the privileged roster the Control Plane traces. + // The Guestbook membership (self-signed joins) plus everyone seen publishing a channel message + // (observed authors, CORD-02 §5) — most members never post a Join, so without the latter the + // roster collapses to just the owner + privileged roles. val guestbookMembers by (session?.members ?: remember { MutableStateFlow(emptySet()) }).collectAsStateWithLifecycle() + val observedAuthors by (session?.observedAuthors ?: remember { MutableStateFlow(emptySet()) }).collectAsStateWithLifecycle() val myPubKey = account.signer.pubKey val roster = - remember(state, guestbookMembers) { + remember(state, guestbookMembers, observedAuthors) { val s = state ?: return@remember emptyList() val authority = s.authority val pubkeys = - (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers) + (listOf(s.ownerPubKey) + authority.roleHolders() + authority.bannedMembers() + guestbookMembers + observedAuthors) .map { it.lowercase() } .distinct() pubkeys - .map { RosterEntry(it, ConcordMembership.of(authority, it)) } - .sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) + .map { + // The member's most-privileged role name (lowest position ranks highest), so the + // roster shows the real "Admin"/"Moderator"/custom label instead of a coarse badge. + val roleName = + authority + .rolesFor(it) + .minByOrNull { r -> r.position } + ?.name + ?.takeIf { n -> n.isNotBlank() } + RosterEntry(it, ConcordMembership.of(authority, it), roleName) + }.sortedWith(compareBy({ it.membership.sortRank() }, { it.pubkey })) } val iAmOwner = state?.authority?.isOwner(myPubKey) == true @@ -209,7 +220,7 @@ private fun ConcordMemberRow( Text(entry.pubkey.take(8), fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) } } - MemberBadge(entry.membership) + MemberBadge(entry.membership, entry.roleName) if (hasMenu) { var expanded by remember { mutableStateOf(false) } IconButton(onClick = { expanded = true }) { @@ -253,14 +264,21 @@ private fun ConcordMemberRow( } } -/** A small pill labelling the member's standing (owner / admin / banned; plain members render nothing). */ +/** A small pill labelling the member's standing (owner / role name / banned; plain members render nothing). */ @Composable -private fun MemberBadge(membership: ConcordMembership) { +private fun MemberBadge( + membership: ConcordMembership, + roleName: String?, +) { val label = - when (membership) { - ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner) - ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin) - ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned) + when { + membership == ConcordMembership.BANNED -> stringRes(R.string.concord_role_banned) + membership == ConcordMembership.OWNER -> stringRes(R.string.concord_role_owner) + // Show the actual granted role ("Admin", "Moderator", or a custom role) rather than a + // one-size-fits-all badge; fall back to the generic "Admin" label if a role-holder's + // role name somehow didn't resolve. + roleName != null -> roleName + membership == ConcordMembership.ADMIN -> stringRes(R.string.concord_role_admin) else -> return } val container = if (membership == ConcordMembership.BANNED) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer @@ -299,6 +317,8 @@ private fun ConcordRemoveMemberDialog( private class RosterEntry( val pubkey: HexKey, val membership: ConcordMembership, + /** The member's most-privileged role name (e.g. "Admin"/"Moderator"), null for a plain member. */ + val roleName: String?, ) /** Owner first, then admins, then plain members, then banned last. */ diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 35e186da8e..76cda1589b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -155,7 +155,11 @@ object ConcordModCommands { sc: StoredCommunity, ): Pair> { val cp = ConcordActions.controlPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) - val wraps = ctx.drain(ConcordCommands.relaysFor(ctx, sc).associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }).map { it.second } + val relays = ConcordCommands.relaysFor(ctx, sc) + // Concord relays serve the plane's kind-1059 only to a connection AUTHed as the derived + // stream key — register the control key so the drain isn't refused (else the fold is empty). + ctx.registerConcordStreamKeys(relays, listOf(cp.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } return cp to ConcordActions.controlEditions(wraps, cp) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 96fe78d233..555d84478e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -130,6 +130,15 @@ class ConcordCommunitySession( /** The live Guestbook membership set (self-signed joins minus later leaves). */ val members: StateFlow> = _members + private val _observedAuthors = MutableStateFlow>(emptySet()) + + /** + * Everyone whose decrypted channel message this session has seen (lowercase hex). CORD-02 §5: + * "an author seen publishing is observably present, auto-included even if their Join never + * arrived." Most members never send a Guestbook Join, so this is the bulk of the real roster. + */ + val observedAuthors: StateFlow> = _observedAuthors + // channelIdHex -> (other member pubkey -> createdAt secs of their latest typing heartbeat). private val typingByChannel = HashMap>() private val _typing = MutableStateFlow>>(emptyMap()) @@ -141,16 +150,17 @@ class ConcordCommunitySession( val typing: StateFlow>> = _typing /** - * The community's full membership (lowercase hex): everyone who announced on the Guestbook, - * plus the owner and every role-holder (who are members whether or not they posted a join), - * minus the banned. Best-effort — a member who joined without a Guestbook motion and holds no - * role is invisible (key possession leaves no trace), so this is a floor, not a census. + * The community's full membership (lowercase hex): everyone who announced on the Guestbook or + * was seen publishing a channel message ([observedAuthors]), plus the owner and every + * role-holder, minus the banned. Best-effort — a member who joined without a Guestbook motion, + * holds no role, and never posted is invisible (key possession leaves no trace), so this is a + * floor, not a census. */ fun allMembers(): Set { val s = _state.value val roster = if (s != null) s.authority.roleHolders() + s.ownerPubKey.lowercase() else emptySet() val banned = s?.authority?.bannedMembers().orEmpty() - return (_members.value + roster) - banned + return (_members.value + _observedAuthors.value + roster) - banned } /** The size of [allMembers] — the community's true (best-effort) member count. */ @@ -308,9 +318,16 @@ class ConcordCommunitySession( val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return // Decrypt + validate every bound rumor and hand it to the sink. The sink dedups // by rumor id, so re-emitting the whole buffer on each fold is idempotent. + val authors = HashSet() ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor -> + authors.add(rumor.pubKey.lowercase()) onRumor(entry.id, channelIdHex, rumor) } + // Every author we just decrypted is observably present (CORD-02 §5), so fold them into the + // roster even if they never posted a Guestbook Join. Only publish when the set actually grew. + if (authors.isNotEmpty() && !_observedAuthors.value.containsAll(authors)) { + _observedAuthors.value = _observedAuthors.value + authors + } } companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 8f2b7ddd9f..83a44f55ee 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -98,16 +98,11 @@ class ConcordCommunityState( channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) } - // Role definitions ride along for display; the AuthorityResolver already owner-roots the - // privileged roster via the grant fixpoint, so a role a rogue defines is inert until an - // authorized granter (who must outrank it and hold MANAGE_ROLES) actually hands it out. - val roles = HashMap() - for (e in heads) { - if (e.entityKind != ControlEntityKind.ROLE) continue - val r = ConcordJson.decodeOrNull(e.content) ?: continue - if (r.deleted) continue - roles[e.entityIdHex] = r - } + // Role definitions come from the authority-gated fold (not the raw structural heads): a + // rogue can mint a higher-version edition on a legit role's coordinate (e.g. marking the + // Admin role deleted) that would win a structural fold and corrupt the displayed roster, + // so we take the roles the AuthorityResolver actually accepted from the owner outward. + val roles = authority.roles() // Dissolution is owner-only — a rogue tombstone must not appear to kill the community. val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 4a49765fe8..bdd7553234 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -48,6 +48,12 @@ class AuthorityResolver private constructor( private val memberRoles: Map>, private val banned: Set, ) { + /** The resolved role definitions (authority-gated), keyed by role id. Safe for display. */ + fun roles(): Map = roles + + /** The role definitions [pubKey] currently holds (empty for the owner and plain members). */ + fun rolesFor(pubKey: String): List = rolesOf(pubKey).mapNotNull { roles[it] } + fun isOwner(pubKey: String): Boolean = pubKey.lowercase() == ownerLower fun isBanned(pubKey: String): Boolean = pubKey.lowercase() in banned From 7cd2be1c7496b02134520bd17f417d2398c35e97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:29:33 +0000 Subject: [PATCH 104/115] feat(concord): channel management + community banner & relay editing Two ways to update a Concord community/its channels that were missing: Channels (net-new): ConcordModeration.defineChannel writes a ChannelEntity control edition (create/rename/delete via version chaining); Account gains createConcordChannel/renameConcordChannel/deleteConcordChannel. The channel-list screen gets a create FAB and a per-row rename/delete menu, all gated on MANAGE_CHANNELS (the same predicate the fold enforces). Community metadata: the edit screen now edits the banner (encrypted ImagePointer upload via the shared banner hero, reusing ConcordImageUploader) and the relay set (add/remove chips + RelayUrlEditField). Also fixes editConcordMetadata silently dropping the banner on every save (it now round-trips it). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 50 ++++- .../concord/ConcordChannelListScreen.kt | 173 +++++++++++++++++- .../concord/ConcordEditScreen.kt | 39 +++- .../concord/ConcordMetadataForm.kt | 95 ++++++++++ amethyst/src/main/res/values/strings.xml | 10 + .../commons/actions/ConcordModeration.kt | 22 +++ 6 files changed, 384 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b5f477560a..d9247f6755 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -151,6 +151,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -2512,16 +2513,63 @@ class Account( name: String, description: String?, icon: ImagePointer?, + banner: ImagePointer?, relays: List, ): Boolean { val session = concordSessions.sessionFor(communityId) ?: return false if (!isWriteable()) return false - val metadata = MetadataEntity(name = name, icon = icon, description = description, relays = relays) + val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now()) publishConcordWrap(session.entry, wrap) return true } + /** + * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold + * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on + * the same predicate. The channel id is a fresh random 32-byte entity id. + */ + suspend fun createConcordChannel( + communityId: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channelId = RandomInstance.bytes(32) + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Rename an existing channel (chains the next channel edition onto its head). MANAGE_CHANNELS only. */ + suspend fun renameConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channel = ChannelEntity(name = name.trim()) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + + /** Delete (tombstone) a channel — terminal; its id is never reused. MANAGE_CHANNELS only. */ + suspend fun deleteConcordChannel( + communityId: String, + channelIdHex: String, + name: String, + ): Boolean { + val session = concordSessions.sessionFor(communityId) ?: return false + if (!isWriteable()) return false + val channel = ChannelEntity(name = name.trim(), deleted = true) + val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + return true + } + /** * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 9f93988af4..8026e282b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -33,10 +33,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -92,10 +96,62 @@ fun ConcordChannelListScreen( var inviteLink by remember { mutableStateOf(null) } var minting by remember { mutableStateOf(false) } + // Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the + // fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer. + val canManageChannels = + state?.authority?.let { + it.isOwner(account.signer.pubKey) || + it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS) + } == true + + // channelIdHex == null → create; else → rename that channel. + var channelEditor by remember { mutableStateOf(null) } + var channelToDelete by remember { mutableStateOf(null) } + inviteLink?.let { link -> InviteLinkDialog(link = link, onDismiss = { inviteLink = null }) } + channelEditor?.let { editor -> + ConcordChannelEditDialog( + initialName = editor.initialName, + isCreate = editor.channelIdHex == null, + onDismiss = { channelEditor = null }, + onConfirm = { newName -> + channelEditor = null + scope.launch { + if (editor.channelIdHex == null) { + account.createConcordChannel(communityId, newName) + } else { + account.renameConcordChannel(communityId, editor.channelIdHex, newName) + } + } + }, + ) + } + + channelToDelete?.let { target -> + val id = target.channelIdHex ?: return@let + AlertDialog( + onDismissRequest = { channelToDelete = null }, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_title)) }, + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_message, target.initialName)) }, + confirmButton = { + TextButton(onClick = { + channelToDelete = null + scope.launch { account.deleteConcordChannel(communityId, id, target.initialName) } + }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { channelToDelete = null }) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) + } + }, + ) + } + Scaffold( topBar = { TopAppBar( @@ -135,6 +191,13 @@ fun ConcordChannelListScreen( }, ) }, + floatingActionButton = { + if (canManageChannels) { + FloatingActionButton(onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") }) { + SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_create)) + } + } + }, ) { padding -> val channels = state @@ -165,7 +228,7 @@ fun ConcordChannelListScreen( Modifier .fillMaxWidth() .clickable { nav.nav(Route.Concord(communityId, entry.key)) } - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(start = 16.dp, top = 14.dp, bottom = 14.dp, end = if (canManageChannels) 4.dp else 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -175,7 +238,13 @@ fun ConcordChannelListScreen( modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text(name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) + Text(name, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1) + if (canManageChannels) { + ConcordChannelRowMenu( + onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + ) + } } HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } @@ -184,6 +253,106 @@ fun ConcordChannelListScreen( } } +/** A pending channel create ([channelIdHex] null) or rename target. */ +private data class ConcordChannelEditor( + val channelIdHex: String?, + val initialName: String, +) + +/** The per-channel-row overflow menu (rename / delete), shown only to channel managers. */ +@Composable +private fun ConcordChannelRowMenu( + onRename: () -> Unit, + onDelete: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + SymbolIcon( + symbol = MaterialSymbols.MoreVert, + contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_rename)) }, + onClick = { + expanded = false + onRename() + }, + ) + DropdownMenuItem( + text = { + Text( + stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + expanded = false + onDelete() + }, + ) + } + } +} + +/** Name-entry dialog for creating a new channel or renaming an existing one. */ +@Composable +private fun ConcordChannelEditDialog( + initialName: String, + isCreate: Boolean, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf(initialName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + stringRes( + if (isCreate) { + com.vitorpamplona.amethyst.R.string.concord_channel_create + } else { + com.vitorpamplona.amethyst.R.string.concord_channel_rename + }, + ), + ) + }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + singleLine = true, + label = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_name_label)) }, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + enabled = name.isNotBlank(), + onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, + ) { + Text( + stringRes( + if (isCreate) { + com.vitorpamplona.amethyst.R.string.concord_channel_create + } else { + com.vitorpamplona.amethyst.R.string.concord_channel_rename_save + }, + ), + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(com.vitorpamplona.amethyst.R.string.cancel)) + } + }, + ) +} + /** Shows a freshly minted invite link as a QR code with copy + share actions. */ @Composable private fun InviteLinkDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 285954831a..68c03da388 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -32,12 +33,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -52,8 +55,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -84,17 +91,24 @@ fun ConcordEditScreen( val name = remember { mutableStateOf("") } val about = remember { mutableStateOf("") } val icon = remember { mutableStateOf(null) } + val banner = remember { mutableStateOf(null) } + val relays = remember { mutableStateListOf() } var prefilled by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - // Seed the fields once, the first time the folded metadata is available. + // Seed the fields once, the first time the folded metadata is available. Relays come from the + // folded metadata when present, else from this account's list entry (the bootstrap set). LaunchedEffect(state?.metadata) { val md = state?.metadata if (!prefilled && md != null) { name.value = md.name about.value = md.description.orEmpty() icon.value = md.icon + banner.value = md.banner + val seededRelays = (md.relays.takeIf { it.isNotEmpty() } ?: session?.entry?.relays.orEmpty()) + relays.clear() + relays.addAll(seededRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }) prefilled = true } } @@ -132,6 +146,26 @@ fun ConcordEditScreen( icon = icon, robotSeed = communityId, accountViewModel = accountViewModel, + banner = banner, + ) + + ConcordSectionHeader( + title = stringRes(R.string.concord_create_relays), + description = stringRes(R.string.concord_edit_relays_desc), + ) + relays.forEach { relay -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = { relays.remove(relay) }) { + SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove)) + } + } + } + RelayUrlEditField( + onNewRelay = { if (it !in relays) relays.add(it) }, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, ) Button( @@ -145,7 +179,8 @@ fun ConcordEditScreen( name = name.value.trim(), description = about.value.trim().ifBlank { null }, icon = icon.value, - relays = state?.metadata?.relays ?: session.entry.relays, + banner = banner.value, + relays = relays.map { it.url }, ) working = false if (ok) nav.popBack() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index ec6254a156..0395f31258 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -24,15 +24,20 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -46,17 +51,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** * The shared metadata form for creating and editing a Concord community — a large circular icon @@ -74,12 +83,15 @@ fun ConcordMetadataFields( robotSeed: String, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + banner: MutableState? = null, ) { Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { + banner?.let { ConcordBannerHero(banner = it, accountViewModel = accountViewModel) } + ConcordIconHero( robotSeed = robotSeed, icon = icon, @@ -173,3 +185,86 @@ private fun ConcordIconHero( ) } } + +/** + * A wide community-banner hero (a 3:1 header image): shows the current decrypted banner, and on tap + * opens the photo picker → AES-256-GCM-encrypts + uploads the image and updates [banner] to the + * resulting CORD-02 §6 encrypted pointer. Tapping when a banner is set replaces it; a small remove + * button clears it. A spinner covers the hero while the upload is in flight. + */ +@Composable +private fun ConcordBannerHero( + banner: MutableState, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var uploading by remember { mutableStateOf(false) } + val bannerModel = rememberConcordImageModel(banner.value, accountViewModel) + + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + uploading = true + scope.launch { + try { + banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: Exception) { + Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show() + } finally { + uploading = false + } + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(3f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + contentAlignment = Alignment.Center, + ) { + if (bannerModel != null) { + AsyncImage( + model = bannerModel, + contentDescription = stringRes(R.string.concord_edit_banner_hint), + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(3f), + ) + } + if (uploading) { + CircularProgressIndicator(modifier = Modifier.size(36.dp)) + } else if (bannerModel == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + SymbolIcon( + symbol = MaterialSymbols.AddPhotoAlternate, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringRes(R.string.concord_edit_banner_hint), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 6.dp), + ) + } + } + if (bannerModel != null && !uploading) { + IconButton( + onClick = { banner.value = null }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + SymbolIcon( + symbol = MaterialSymbols.Close, + contentDescription = stringRes(R.string.remove), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f7bbe180e6..f80f1a97bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -314,6 +314,16 @@ Show all channels Send image Open channel + Add a banner + New channel + Rename channel + Rename + Channel name + Delete channel + Delete channel? + Delete #%1$s? This can\'t be undone and the channel can\'t be recreated with the same id. + Delete + Where this community\'s encrypted planes are published and read. %1$s is typing… %1$s and %2$s are typing… Several people are typing… diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index c38f3858a4..31c25887cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder @@ -97,6 +98,27 @@ object ConcordModeration { return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) } + /** + * Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable + * 32-byte entity id — generate one for a new channel and reuse it to rename, flip its + * private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused). + * Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner + * via [citation]. + */ + suspend fun defineChannel( + actor: NostrSigner, + controlPlane: GroupKey, + channelId: ByteArray, + channel: ChannelEntity, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + ): Event { + val (version, prev) = versioning(current, ControlEntityKind.CHANNEL, channelId) + val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel) + return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) + } + /** * Replaces the community metadata (name / icon / description / relays). The * metadata entity id is the community id itself (as in genesis), so this chains From c482af057877b98e636482327c7b759346072731 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:36:41 +0000 Subject: [PATCH 105/115] feat(concord): custom-emoji autocomplete in the channel composer Wire the shared NIP-30 custom-emoji picker into the Concord composer like the @-mention flow: typing `:shortcode:` opens ShowEmojiSuggestionList (backed by EmojiSuggestionState(account.emoji)); WatchAndLoadMyEmojiList loads the user's packs. On send, account.emoji.findEmojiTags(text) attaches the NIP-30 emoji tags to the kind-9 rumor (plain message + inline reply), so recipients render the custom image inline via the shared chat renderer. Image uploads in messages, icon and banner were already wired. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../com/vitorpamplona/amethyst/model/Account.kt | 8 ++++++-- .../concord/ConcordChannelScreen.kt | 13 +++++++++++++ .../concord/send/ConcordNewMessageViewModel.kt | 15 +++++++++++++++ .../amethyst/commons/actions/ConcordActions.kt | 6 ++++-- .../quartz/concord/cord03Channels/ChannelChat.kt | 3 ++- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d9247f6755..e41e6900d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2074,6 +2074,10 @@ class Account( val entry = session.entry val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the + // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). + val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() + val parent = replyTo?.event val wrap = when { @@ -2082,9 +2086,9 @@ class Account( parent != null && replyMode == ReplyMode.MINICHAT -> ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) parent != null -> - ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) + ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) else -> - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now()) + ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) } publishConcordWrap(entry, wrap) return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 0121df89f4..e01fbd98b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -55,6 +55,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.ui.feeds.DmHistoryLoadingCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState @@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.RefreshingChatroomFeedView @@ -129,6 +131,8 @@ fun ConcordChannelScreen( nav: INav, ) { ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + // Load the user's custom-emoji packs so the `:shortcode:` composer autocomplete has entries. + WatchAndLoadMyEmojiList(accountViewModel) ConcordChannelHistorySubscription(communityId, channelId, accountViewModel.dataSources().concordChannelHistory, accountViewModel) val account = accountViewModel.account @@ -412,6 +416,15 @@ private fun ConcordMessageComposer( ) } + newMessageModel.emojiSuggestions?.let { + ShowEmojiSuggestionList( + it, + newMessageModel::autocompleteWithEmoji, + newMessageModel::autocompleteWithEmoji, + SuggestionListDefaultHeightChat, + ) + } + ThinPaddingTextField( state = newMessageModel.message, onTextChanged = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 039319003d..7f1941bb1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -28,6 +28,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.amethyst.model.Account @@ -66,6 +68,7 @@ open class ConcordNewMessageViewModel : ViewModel() { val replyMode = mutableStateOf(ReplyMode.INLINE) var userSuggestions: UserSuggestionState? = null + var emojiSuggestions: EmojiSuggestionState? = null // Encrypted image attachments ride through the shared NIP-17 upload pipeline; a picked image // opens the upload dialog, which encrypts + uploads and sends an Armada-shaped image message. @@ -84,6 +87,9 @@ open class ConcordNewMessageViewModel : ViewModel() { priorityPubkeys = { channelAuthors() }, ) + this.emojiSuggestions?.reset() + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) + this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -145,8 +151,13 @@ open class ConcordNewMessageViewModel : ViewModel() { val lastWord = message.currentWord() if (lastWord.startsWith("@")) { userSuggestions?.processCurrentWord(lastWord) + emojiSuggestions?.reset() + } else if (lastWord.startsWith(":")) { + emojiSuggestions?.processCurrentWord(lastWord) + userSuggestions?.reset() } else { userSuggestions?.reset() + emojiSuggestions?.reset() } } } @@ -158,6 +169,10 @@ open class ConcordNewMessageViewModel : ViewModel() { } } + fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { + emojiSuggestions?.autocompleteInto(message, item) + } + /** Sends the field's text as a channel message (or a reply). Throws on failure. */ suspend fun sendPost() { val community = communityId ?: return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index dce6d62b3a..2b17bb40df 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -156,8 +156,9 @@ object ConcordActions { epoch: Long, text: String, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { - val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt) + val rumor = ChannelChat.message(authorSigner.pubKey, channelId, epoch, text, createdAt, extraTags) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } @@ -187,8 +188,9 @@ object ConcordActions { parent: Event, text: String, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { - val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt) + val rumor = ChannelChat.inlineReply(authorSigner.pubKey, channelId, epoch, text, parent.id, parent.pubKey, createdAt, extraTags) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index e878436ae4..91f58a731c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -89,6 +89,7 @@ object ChannelChat { parentId: HexKey, parentAuthor: HexKey, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event = message( authorPubKey = authorPubKey, @@ -96,7 +97,7 @@ object ChannelChat { epoch = epoch, text = text, createdAt = createdAt, - extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)), + extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, ) /** From b6a238b71a4e5c8dac72a3dfeb13db8d0a49c389 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:50:27 +0000 Subject: [PATCH 106/115] fix(concord): mute the community-name pill now that the logo is the avatar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Concord community chip (Messages header + Notifications) no longer needs the strong secondaryContainer highlight — the community's logo is now the row avatar, so the name reads as faint tappable metadata (same wash as the note-header markers). The NIP-29 relay-host chip (RelayNameChip) keeps its highlight; a relay group has no avatar of its own. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordCommunityPill.kt | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt index 45cd285ceb..4fa775cc6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCommunityPill.kt @@ -36,13 +36,15 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.theme.placeholderText /** - * A tappable chip naming the Concord community a message belongs to. Unlike the muted note-header - * markers (PoW/OTS/location), this is a first-class navigation entry point, so it keeps a strong - * `secondaryContainer` highlight. Shared by the Messages row and the Notifications feed so a Concord - * message reads the same wherever it surfaces. The name is hard-capped so a long title can't crowd - * the row. + * A tappable chip naming the Concord community a message belongs to. Deliberately **muted** — the same + * faint wash the note-header markers use — because the community's logo is now the row avatar, so the + * name only needs to read as tappable metadata, not compete with it. (The NIP-29 relay-host chip stays + * highlighted; a relay group has no avatar of its own.) Shared by the Messages row and the Notifications + * feed so a Concord message reads the same wherever it surfaces; the name is hard-capped so a long title + * can't crowd the row. */ @Composable fun ConcordCommunityPill( @@ -52,7 +54,8 @@ fun ConcordCommunityPill( ) { Surface( shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + contentColor = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.clickable(onClick = onClick), ) { Row( @@ -63,13 +66,13 @@ fun ConcordCommunityPill( Icon( symbol = MaterialSymbols.Group, contentDescription = null, - tint = MaterialTheme.colorScheme.onSecondaryContainer, + tint = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(11.dp), ) Text( text = if (communityName.length > maxChars) communityName.take(maxChars).trimEnd() + "…" else communityName, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) From 3950323ba315d75d6f5bd4fa1c52105870cb82d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 00:37:13 +0000 Subject: [PATCH 107/115] =?UTF-8?q?fix(concord):=20audit=20fixes=20?= =?UTF-8?q?=E2=80=94=20memory,=20folding,=20concurrency,=20notifications,?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the deep-audit findings across the new Concord code: - H1: stop persisting kind-21059 ephemeral typing wraps as durable notes. EphemeralGiftWrapEvent extends GiftWrapEvent, so every heartbeat was stored forever; drop it once the session has ingested it. - H2: follow()/unfollow() now read the offline backup (entriesWithBackup), so a join racing the async backup load can no longer wipe the joined list. - M1 (banlist): fold to the head (honors a chained unban) then union in authorized editions that aren't ancestors of the head — concurrent bans are healed without resurrecting an on-chain unban (CORD-06 down-only). - M2: reproject only the newly-arrived channel wrap incrementally instead of re-decrypting the whole buffer per message (was O(n^2)); refold only projects newly-folded channels. - M3: cancel a session's old state-watcher before replacing it on a Refounding rebuild (was a coroutine + session leak per rekey). - M4: publish typing/state/members/observed-authors under the lock and make revision/observedAuthors updates atomic; clamp future-dated typing. - M5: notification Concord bypass now requires the community to be one this account has currently joined (mirrors the Marmot guard). - M6: Concord chat honors the "Messages in notifications" toggle. - L1: carry NIP-30 emoji tags on minichat replies, image captions and custom-emoji reactions. - C1: make the composer VM init() idempotent so recomposition can't wipe a picked image or an open suggestion list. - C2/C3: ConcordHome channel rows and unread badges react to the channel's own notes flow instead of the global revision (no stale rows / flicker). - C4: try/finally around mint-invite / create / save so a thrown call can't strand the button disabled. - C5: gate the typing ticker on active heartbeats so an idle channel stops waking a 2s loop. Adds regression tests for concurrent-ban union-heal and unauthorized bans. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++- .../loggedIn/DecryptAndIndexProcessor.kt | 13 ++- .../concord/ConcordChannelListScreen.kt | 9 +- .../concord/ConcordChannelScreen.kt | 10 +- .../concord/ConcordCreateScreen.kt | 18 ++-- .../concord/ConcordEditScreen.kt | 22 +++-- .../concord/ConcordHomeScreen.kt | 33 +++++-- .../send/ConcordNewMessageViewModel.kt | 4 + .../dal/NotificationFeedFilter.kt | 14 ++- .../commons/actions/ConcordActions.kt | 9 +- .../model/concord/ConcordChannelListState.kt | 10 +- .../model/concord/ConcordCommunitySession.kt | 95 ++++++++++++------- .../model/concord/ConcordSessionManager.kt | 13 ++- .../concord/cord03Channels/ChannelChat.kt | 8 +- .../concord/cord04Roles/AuthorityResolver.kt | 55 +++++++++-- .../cord04Roles/AuthorityResolverTest.kt | 59 +++++++++--- 16 files changed, 284 insertions(+), 99 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index e41e6900d2..25ba607e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2084,7 +2084,7 @@ class Account( // A minichat reply is a kind-1111 thread comment; an inline reply is a kind-9 // message quoting the parent; a fresh post is a plain kind-9 message. parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now()) + ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) parent != null -> ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) else -> @@ -2111,7 +2111,9 @@ class Account( val session = concordSessions.sessionFor(communityId) ?: return false val entry = session.entry val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now()) + // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. + val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() + val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } @@ -2187,7 +2189,10 @@ class Account( val entry = concordSessions.sessionFor(communityId)?.entry ?: return false val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now()) + // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to + // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. + val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() + val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 28ca3c027f..bd284893cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallHangupEvent @@ -298,7 +299,17 @@ class GiftWrapEventHandler( // the payload opens with a derived plane key, not our identity — so route // them to the Concord read-path first. A recognized wrap is fully handled // there (folded / re-projected) and must not fall through to the DM path. - if (account.concordSessions.ingest(event)) return + if (account.concordSessions.ingest(event)) { + // Concord typing heartbeats ride kind-21059 ephemeral wraps that arrive + // continuously while anyone in any joined community is composing. NIP-01 + // ephemeral events (20000–29999) must never be persisted; the session has + // already folded the state they carried, so drop the durable wrap note now + // to keep LocalCache from growing without bound. + if (event is EphemeralGiftWrapEvent) { + cache.unlinkAndRemove(listOf(eventNote)) + } + return + } if (event.recipientPubKey() != account.signer.pubKey) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 8026e282b5..71c9788eb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -181,8 +181,13 @@ fun ConcordChannelListScreen( onClick = { minting = true scope.launch { - inviteLink = account.mintConcordInvite(communityId) - minting = false + try { + inviteLink = account.mintConcordInvite(communityId) + } finally { + // Always clear the flag — a thrown mint would otherwise leave the + // button disabled until the screen is recreated. + minting = false + } } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index e01fbd98b3..5874c454cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -301,10 +301,16 @@ private fun ConcordTypingIndicator( val typingMap by session.typing.collectAsStateWithLifecycle() var nowSecs by remember { mutableLongStateOf(TimeUtils.now()) } - LaunchedEffect(session) { + // Only tick while this channel actually has heartbeats, and stop once they've all aged out of + // the freshness window — an idle channel must not wake a 2s recomposition loop forever. A new + // heartbeat re-keys this effect (the map value changes) and restarts the fade. + LaunchedEffect(session, channelId, typingMap[channelId]) { + val perChannel = typingMap[channelId] + if (perChannel.isNullOrEmpty()) return@LaunchedEffect while (true) { - delay(2000L) nowSecs = TimeUtils.now() + if (perChannel.values.none { nowSecs - it <= ConcordCommunitySession.TYPING_STALE_SECS }) break + delay(2000L) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index a2077a1097..913eef64cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -130,13 +130,17 @@ fun ConcordCreateScreen( working = true scope.launch { val communityId = - accountViewModel.account.createConcordCommunity( - name = name.value.trim(), - description = about.value.trim().ifBlank { null }, - relays = relays.map { it.url }, - icon = icon.value, - ) - working = false + try { + accountViewModel.account.createConcordCommunity( + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, + relays = relays.map { it.url }, + icon = icon.value, + ) + } finally { + // Always re-enable — a thrown create would otherwise strand the button. + working = false + } if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 68c03da388..28fa6690b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -174,15 +174,19 @@ fun ConcordEditScreen( working = true scope.launch { val ok = - account.editConcordMetadata( - communityId = communityId, - name = name.value.trim(), - description = about.value.trim().ifBlank { null }, - icon = icon.value, - banner = banner.value, - relays = relays.map { it.url }, - ) - working = false + try { + account.editConcordMetadata( + communityId = communityId, + name = name.value.trim(), + description = about.value.trim().ifBlank { null }, + icon = icon.value, + banner = banner.value, + relays = relays.map { it.url }, + ) + } finally { + // Always re-enable — a thrown save would otherwise strand the button. + working = false + } if (ok) nav.popBack() } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index cbc0022be3..f76e8dad06 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -230,7 +229,6 @@ fun ConcordHomeScreen( def?.private == true -> MaterialSymbols.Lock else -> MaterialSymbols.Tag }, - revision = revision, hideIfRead = mode == ChannelExpand.UNREAD, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, @@ -270,15 +268,22 @@ private fun communityUnreadCount( account: Account, communityId: String, channelKeys: Set, - revision: Int, ): Int { if (channelKeys.isEmpty()) return 0 + // Keyed only on the channel set (not the global revision): each per-channel flow reacts to both + // its last-read marker AND the channel's own notes flow, so a folded message flips the badge + // without tearing down and restarting every flow on every unrelated fold (which reset the badge + // to 0 and made it flicker). val flow = - remember(communityId, channelKeys, revision) { + remember(communityId, channelKeys) { combine( channelKeys.map { key -> - account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)).map { lastRead -> - val last = LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, key))?.lastNote?.createdAt() ?: 0L + val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, key)) + combine( + account.loadLastReadFlow(concordChannelLastReadRoute(communityId, key)), + channel.flow().notes.stateFlow, + ) { lastRead, state -> + val last = state.channel.lastNote?.createdAt() ?: 0L if (last > lastRead) 1 else 0 } }, @@ -301,7 +306,7 @@ private fun CommunityHeader( ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() val iconModel = rememberConcordImageModel(iconPointer, accountViewModel) - val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys, revision) + val unread = communityUnreadCount(accountViewModel.account, communityId, channelKeys) // Tap cycles CLOSED → UNREAD → OPEN → CLOSED, skipping the UNREAD peek when nothing is unread // (so a quiet community never lands on an empty middle state). val next = @@ -412,14 +417,22 @@ private fun ConcordChannelRow( channelKey: String, channelName: String, icon: MaterialSymbol, - revision: Int, hideIfRead: Boolean, accountViewModel: AccountViewModel, onClick: () -> Unit, ) { val account = accountViewModel.account - val channel = remember(communityId, channelKey) { LocalCache.getConcordChannelIfExists(ConcordChannelId(communityId, channelKey)) } - val lastNote = remember(revision, channel) { channel?.lastNote } + // getOrCreate (not getIfExists): a channel folded in the control plane may have no message-buffer + // note yet, and caching that null for the row's lifetime would leave it perpetually blank. The + // channel's own notes flow then makes lastNote reactive, so the preview/unread dot appears the + // moment its first message folds in — without keying on the global revision (which flickered the + // whole row on every unrelated fold). + val channel = remember(communityId, channelKey) { LocalCache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelKey)) } + val channelState by channel + .flow() + .notes.stateFlow + .collectAsStateWithLifecycle() + val lastNote = channelState.channel.lastNote val lastReadTime by account.loadLastReadFlow(concordChannelLastReadRoute(communityId, channelKey)).collectAsStateWithLifecycle() val unread = (lastNote?.createdAt() ?: Long.MIN_VALUE) > lastReadTime diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt index 7f1941bb1d..f104994923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/send/ConcordNewMessageViewModel.kt @@ -75,6 +75,10 @@ open class ConcordNewMessageViewModel : ViewModel() { var uploadState by mutableStateOf(null) open fun init(accountVM: AccountViewModel) { + // Idempotent: the screen calls init() on every recomposition, and it recomposes often while + // paging history. Rebuilding uploadState/suggestion state each time would wipe a picked image + // mid-upload or reset an open @/emoji suggestion list, so only (re)build when the account changes. + if (::accountViewModel.isInitialized && this.accountViewModel === accountVM) return this.accountViewModel = accountVM this.account = accountVM.account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index fc4e7bdd8a..5618fbcaf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -444,7 +444,19 @@ class NotificationFeedFilter( // in a community I've joined is relevant whether or not I follow that member (fellow members // usually aren't follows). The p-tag gate below still applies, so only genuine replies / // reactions / mentions notify — general channel chatter that doesn't tag me never does. - val isConcord = it.inGatherers?.any { g -> g is ConcordChannel } == true + // + // A ConcordChannel gatherer alone isn't enough: notes live in the global LocalCache and keep a + // gatherer reference from every account/community that ever touched them, so require the + // community to be one THIS account has currently joined (mirrors the Marmot check above) — + // otherwise a note from a prior account or a left community would leak onto Notifications. + val isConcord = + it.inGatherers?.any { g -> + g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null + } == true + + // Concord is a messaging feature, so honor the same "Messages in notifications" toggle that + // silences DMs and Marmot groups above. + if (isConcord && !showMessages) return false // Global keeps every event that p-tags the user; Selected (and the // follow/list modes) also applies the per-kind relevance heuristics. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 2b17bb40df..c975e232e0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -174,8 +174,9 @@ object ConcordActions { text: String, imetas: List, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { - val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt) + val rumor = ChannelChat.imageMessage(authorSigner.pubKey, channelId, epoch, text, imetas, createdAt, extraTags) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } @@ -203,8 +204,9 @@ object ConcordActions { parent: Event, text: String, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { - val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt) + val rumor = ChannelChat.reply(authorSigner.pubKey, channelId, epoch, text, parent, createdAt, extraTags) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } @@ -217,8 +219,9 @@ object ConcordActions { target: Event, reaction: String, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { - val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt) + val rumor = ChannelChat.reaction(authorSigner.pubKey, channelId, epoch, target.id, target.pubKey, target.kind, reaction, createdAt, extraTags) return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt index 7a3e135fb2..830bd0590e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -103,15 +103,19 @@ class ConcordChannelListState( /** Add or replace [entry] (by community id) and return the new signed list event to publish. */ suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent { - val current = getConcordList()?.decrypt(signer).orEmpty() + // Seed from the offline backup as well as the live cache event: the saved list is + // consumed into the cache asynchronously in `init`, so a join that races that load + // would otherwise start from an empty `current` and wipe every prior membership. + val current = entriesWithBackup(concordListNote) val next = current.filterNot { it.id == entry.id } + entry return ConcordCommunityListEvent.create(signer, next) } /** Drop the community with [communityId] and return the new list event, or null if none existed. */ suspend fun unfollow(communityId: String): ConcordCommunityListEvent? { - val event = getConcordList() ?: return null - val next = event.decrypt(signer).filterNot { it.id == communityId } + val current = entriesWithBackup(concordListNote) + if (current.none { it.id == communityId }) return null + val next = current.filterNot { it.id == communityId } return ConcordCommunityListEvent.create(signer, next) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 555d84478e..ad29174e4c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update /** * A validated inner chat rumor emitted by a session: its parent [communityId] and @@ -258,10 +259,15 @@ class ConcordCommunitySession( ingestTyping(wrap, channelIdHex, key) return ConcordIngestOutcome.NON_STRUCTURAL } - lock.withLock { - channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) - } - reprojectChannel(channelIdHex) + val isNew = + lock.withLock { + channelWrapsById.getOrPut(channelIdHex) { LinkedHashMap() }.put(wrap.id, wrap) == null + } + // Project only the newly-arrived wrap — the buffer's earlier wraps were already + // emitted when they landed, so re-decrypting the whole history on every message + // would be O(history) per message (quadratic over a channel's lifetime). A duplicate + // re-delivery (isNew == false) is a no-op. + if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap)) // A chat message lands in the feed via [onRumor] → LocalCache, independent of the // revision; it changes no plane address, so it must NOT bump (see the storm note above). return ConcordIngestOutcome.NON_STRUCTURAL @@ -279,54 +285,77 @@ class ConcordCommunitySession( val who = rumor.pubKey.lowercase() if (who == myPubKey.lowercase()) return // never show my own typing back to me val now = TimeUtils.now() - val snapshot = - lock.withLock { - val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() } - val prev = perChannel[who] - if (prev == null || rumor.createdAt > prev) perChannel[who] = rumor.createdAt - perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS } - if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex) - typingByChannel.mapValues { it.value.toMap() } - } - _typing.value = snapshot + // Update the map and publish inside the lock so a concurrent heartbeat on another + // channel can't publish an older snapshot last and drop this channel's typers. + lock.withLock { + val perChannel = typingByChannel.getOrPut(channelIdHex) { HashMap() } + val prev = perChannel[who] + // Clamp a peer's heartbeat to our clock: a wildly future-dated createdAt would never + // fall out of the freshness window below and would block later real heartbeats. + val stamp = minOf(rumor.createdAt, now) + if (prev == null || stamp > prev) perChannel[who] = stamp + perChannel.entries.retainAll { now - it.value <= TYPING_STALE_SECS } + if (perChannel.isEmpty()) typingByChannel.remove(channelIdHex) + _typing.value = typingByChannel.mapValues { it.value.toMap() } + } } private fun refold() { - val wraps = lock.withLock { controlWraps.values.toList() } - val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) - _state.value = folded + // Read the buffer, fold, re-derive channel keys, and publish state atomically under the + // lock so a concurrent control wrap can't publish a smaller fold last. Control editions + // are rare (not per-message), so serializing the fold is cheap. + val newChannels = + lock.withLock { + val wraps = controlWraps.values.toList() + val folded = ConcordActions.foldCommunity(wraps, controlPlaneKey, entry.owner) - // Re-derive channel plane addresses from the fresh fold. - val next = HashMap>() - for (channelIdHex in folded.channels.keys) { - val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - next[key.publicKeyHex] = channelIdHex to key - } - lock.withLock { channelKeysByAddress = next } + val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } + val next = HashMap>() + for (channelIdHex in folded.channels.keys) { + val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) + next[key.publicKeyHex] = channelIdHex to key + } + channelKeysByAddress = next + _state.value = folded + folded.channels.keys.filterNot { it in prevChannels } + } - // Any channel wraps already buffered can now project. - for (channelIdHex in folded.channels.keys) reprojectChannel(channelIdHex) + // Project only channels appearing for the first time. Existing channels' wraps were already + // emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so + // a channel's buffer never pre-dates its first fold) — re-projecting all channels on every + // control edition would be O(channels × history) of redundant decryption. + for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } private fun refoldGuestbook() { - val wraps = lock.withLock { guestbookWraps.values.toList() } - _members.value = ConcordActions.guestbookMembers(wraps, guestbookKey) + lock.withLock { + val wraps = guestbookWraps.values.toList() + _members.value = ConcordActions.guestbookMembers(wraps, guestbookKey) + } } private fun reprojectChannel(channelIdHex: HexKey) { val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return - // Decrypt + validate every bound rumor and hand it to the sink. The sink dedups - // by rumor id, so re-emitting the whole buffer on each fold is idempotent. + emitChannelRumors(channelIdHex, key, wraps) + } + + /** Decrypt + validate the given [wraps], hand each bound rumor to the sink, and fold its author into + * the observed roster. The sink dedups by rumor id, so re-emitting a wrap is idempotent. */ + private fun emitChannelRumors( + channelIdHex: HexKey, + key: GroupKey, + wraps: List, + ) { val authors = HashSet() ConcordActions.channelRumors(wraps, key, channelIdHex, entry.rootEpoch).forEach { rumor -> authors.add(rumor.pubKey.lowercase()) onRumor(entry.id, channelIdHex, rumor) } // Every author we just decrypted is observably present (CORD-02 §5), so fold them into the - // roster even if they never posted a Guestbook Join. Only publish when the set actually grew. - if (authors.isNotEmpty() && !_observedAuthors.value.containsAll(authors)) { - _observedAuthors.value = _observedAuthors.value + authors + // roster even if they never posted a Guestbook Join. Atomic so a concurrent add isn't lost. + if (authors.isNotEmpty()) { + _observedAuthors.update { if (it.containsAll(authors)) it else it + authors } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 567bb58fb4..8062fbc359 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -86,9 +87,14 @@ class ConcordSessionManager( val departed = stateWatchers.keys.filterNot { it in wantedIds } for (id in departed) stateWatchers.remove(id)?.cancel() - // Watch each newly-created session so its folds bump the revision. + // Watch each newly-created session so its folds bump the revision. A Refounding + // rebuilds a still-joined community's session in place (same id, new root/epoch), + // so it comes back in `created` while its old watcher is still running — cancel + // that stale collector before replacing the map entry, or every Refounding leaks + // a coroutine holding a dead session and bumping the revision forever. for (id in created) { val session = registry.sessionFor(id) ?: continue + stateWatchers.remove(id)?.cancel() stateWatchers[id] = scope.launch { session.state.collect { bumpRevision() } @@ -99,7 +105,10 @@ class ConcordSessionManager( } private fun bumpRevision() { - _revision.value = _revision.value + 1 + // Called from the communities collector, every per-session state watcher, and the + // ingest path — different coroutines/dispatchers — so the increment must be atomic + // or concurrent bumps are lost. + _revision.update { it + 1 } } /** The `authors` set (control + known channel planes) for the kind-1059 subscription. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 91f58a731c..9c4f79135e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -121,11 +121,13 @@ object ChannelChat { text: String, parent: Event, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event = RumorAssembler.assembleRumor( authorPubKey, CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { channelBinding(channelId, epoch) + extraTags.forEach { add(it) } }, ) @@ -146,6 +148,7 @@ object ChannelChat { targetKind: Int, content: String, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, @@ -158,7 +161,7 @@ object ChannelChat { arrayOf("e", targetId), arrayOf("p", targetAuthor), arrayOf("k", targetKind.toString()), - ), + ) + extraTags, content = content, ) @@ -177,6 +180,7 @@ object ChannelChat { text: String, imetas: List, createdAt: Long, + extraTags: Array> = emptyArray(), ): Event { val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") @@ -186,7 +190,7 @@ object ChannelChat { epoch = epoch, text = finalText, createdAt = createdAt, - extraTags = imetas.map { it.toTagArray() }.toTypedArray(), + extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index bdd7553234..7ddf32576d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + /** * Resolves the owner-rooted authority state of a Concord community from its * folded Control Plane (CORD-04). @@ -201,18 +203,53 @@ class AuthorityResolver private constructor( return acc } - // Banlist: honored only from a signer holding BAN (or the owner), then healed to the head. - val banHead = - EditionFold.foldEntity( - editions.filter { - it.entityKind == ControlEntityKind.BANLIST && - (it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN)) - }, - ) + // Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single + // replaced doc, so fold its chain to the head first — that honors a legitimate unban, which + // is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent + // forks: two moderators who ban different abusers at the same chain version fork the doc, and + // folding to one head would silently drop the other's ban. Union in every authorized edition + // that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed. + // Ancestors (superseded by the chain, including an unban's now-cleared target) are already + // reflected by the head and must not be resurrected. This is CORD-06's "down-only healing": + // a concurrent ban is never lost, while an on-chain unban still takes effect. + val authorizedBanlist = + editions.filter { + it.entityKind == ControlEntityKind.BANLIST && + (it.author.lowercase() == ownerLower || effectivePermissionsOf(it.author.lowercase()).has(ConcordPermissions.BAN)) + } val banned = HashSet() - banHead?.let { ConcordJson.decodeBanlist(it.content) }?.forEach { banned.add(it.lowercase()) } + val banHead = EditionFold.foldEntity(authorizedBanlist) + if (banHead != null) { + ConcordJson.decodeBanlist(banHead.content)?.forEach { banned.add(it.lowercase()) } + val ancestry = banlistAncestry(banHead, authorizedBanlist) + for (edition in authorizedBanlist) { + if (edition.hashHex !in ancestry) { + ConcordJson.decodeBanlist(edition.content)?.forEach { banned.add(it.lowercase()) } + } + } + } return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned) } + + /** + * The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains + * from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by + * the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also + * terminates on any cycle. + */ + private fun banlistAncestry( + head: ControlEdition, + pool: List, + ): Set { + val byHash = pool.associateBy { it.hashHex } + val acc = HashSet() + var cur: ControlEdition? = head + while (cur != null && acc.add(cur.hashHex)) { + val prev = cur.prevHash?.toHexKey() + cur = if (prev != null) byHash[prev] else null + } + return acc + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index def1241c6c..c7c7b203dd 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -67,18 +67,23 @@ class AuthorityResolverTest { 0, ) - private fun banlist(vararg banned: String) = - ControlEdition( - ControlEntityKind.BANLIST, - "44".repeat(32).hexToByteArray(), - 0, - null, - null, - "[${banned.joinToString(",") { "\"$it\"" }}]", - owner, - "ban", - 0, - ) + private fun banlist(vararg banned: String) = banlistBy(owner, "ban", *banned) + + private fun banlistBy( + author: String, + rumorId: String, + vararg banned: String, + ) = ControlEdition( + ControlEntityKind.BANLIST, + "44".repeat(32).hexToByteArray(), + 0, + null, + null, + "[${banned.joinToString(",") { "\"$it\"" }}]", + author, + rumorId, + 0, + ) @Test fun ranksPermissionsAndActionAuthorityAreOwnerRooted() { @@ -152,6 +157,36 @@ class AuthorityResolverTest { assertFalse(r.canActOn(alice, bob, BAN)) } + @Test + fun concurrentBansHealIntoAUnionAndAreNeverDropped() { + // Two authorized moderators ban different abusers at the same banlist version — a + // fork of the single banlist doc. Folding to one chain tip would silently drop the + // loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06 + // down-only healing). + val heads = + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), // alice gains BAN + banlistBy(owner, "ban-owner", bob), // owner bans bob + banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol + ) + val r = AuthorityResolver.resolve(heads, owner) + assertTrue(r.isBanned(bob)) + assertTrue(r.isBanned(carol)) + } + + @Test + fun banlistEditionsFromUnauthorizedSignersAreIgnored() { + // carol holds no BAN permission, so her ban of dave must not take effect. + val heads = + listOf( + role(adminRole, adminJson), + banlistBy(carol, "ban-carol", dave), + ) + val r = AuthorityResolver.resolve(heads, owner) + assertFalse(r.isBanned(dave)) + } + @Test fun deletedRolesAndPositionZeroAreDropped() { val heads = From d0a817a607c642ef17aedbc81afd09d5e24b547c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:42:01 +0000 Subject: [PATCH 108/115] style(concord): drop bold top-bar titles to match the app's default weight The shared TopBarWithBackButton renders its title as a plain Text (Material3's default title weight), and screens like the Marmot group list follow that. The Concord screens (and the minichat screen added alongside them) hardcoded FontWeight.Bold on their TopAppBar titles, standing out from every other screen. Remove the bold so the nav bars read consistently; content emphasis (unread markers, names, section headers) is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../ui/screen/loggedIn/chats/minichat/MinichatScreen.kt | 3 +-- .../chats/publicChannels/concord/ConcordChannelListScreen.kt | 2 +- .../chats/publicChannels/concord/ConcordChannelScreen.kt | 3 +-- .../chats/publicChannels/concord/ConcordCreateScreen.kt | 2 +- .../loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt | 3 +-- .../loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt | 2 +- .../chats/publicChannels/concord/ConcordMembersScreen.kt | 2 +- 7 files changed, 7 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt index f12aba10f5..b7bc4e23bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/minichat/MinichatScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -141,7 +140,7 @@ fun MinichatScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(R.string.chat_minichat_title), fontWeight = FontWeight.Bold, maxLines = 1) }, + title = { Text(stringRes(R.string.chat_minichat_title), maxLines = 1) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 71c9788eb8..959efe2288 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -155,7 +155,7 @@ fun ConcordChannelListScreen( Scaffold( topBar = { TopAppBar( - title = { Text(state?.metadata?.name ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name), fontWeight = FontWeight.Bold, maxLines = 1) }, + title = { Text(state?.metadata?.name ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name), maxLines = 1) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index 5874c454cb..c18bd22474 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -172,7 +171,7 @@ fun ConcordChannelScreen( TopAppBar( title = { Column { - Text(channel.toBestDisplayName(), fontWeight = FontWeight.Bold, maxLines = 1) + Text(channel.toBestDisplayName(), maxLines = 1) channel.communityName?.let { Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 913eef64cd..c35b1fd8d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -79,7 +79,7 @@ fun ConcordCreateScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title), fontWeight = FontWeight.Bold) }, + title = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_create_title)) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 28fa6690b1..837f615dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -116,7 +115,7 @@ fun ConcordEditScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(R.string.concord_edit_title), fontWeight = FontWeight.Bold, maxLines = 1) }, + title = { Text(stringRes(R.string.concord_edit_title), maxLines = 1) }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index f76e8dad06..632e929f77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -119,7 +119,7 @@ fun ConcordHomeScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringRes(R.string.concord_home_title), fontWeight = FontWeight.Bold) }, + title = { Text(stringRes(R.string.concord_home_title)) }, navigationIcon = { // Back arrow only when this is a pushed screen (from the drawer / a deep link); // as a bottom-nav root there is nothing to pop and the bar takes its place. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index fba6ddb27b..8935f1e3a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -131,7 +131,7 @@ fun ConcordMembersScreen( TopAppBar( title = { Column { - Text(stringRes(R.string.concord_members_title), fontWeight = FontWeight.Bold, maxLines = 1) + Text(stringRes(R.string.concord_members_title), maxLines = 1) state?.metadata?.name?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.labelSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) } From a06cdd82fe6c06ffd0444f54c5c8f07da181eb8f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:54:32 +0000 Subject: [PATCH 109/115] feat(concord): rich, clickable relay rows in the community create/edit form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Concord create/edit screens showed each community relay as a bare URL string with a remove button — far more basic than every other relay list in the app. Extract a shared ConcordRelayListEditor that renders each relay the way the Relay Settings / Marmot screens do: the relay's NIP-11 favicon, its advertised name, and its host, with the row tapping through to the full relay-info page (Route.RelayInfo) and long-press copying the URL. Both screens now call the one editor (also removes the duplicated relay block). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../concord/ConcordCreateScreen.kt | 19 +--- .../concord/ConcordEditScreen.kt | 19 +--- .../concord/ConcordMetadataForm.kt | 89 +++++++++++++++++++ 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index c35b1fd8d4..e92283510e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -42,7 +41,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -50,11 +48,9 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -109,17 +105,10 @@ fun ConcordCreateScreen( title = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays), description = stringRes(com.vitorpamplona.amethyst.R.string.concord_create_relays_desc), ) - relays.forEach { relay -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - IconButton(onClick = { relays.remove(relay) }) { - SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.remove)) - } - } - } - RelayUrlEditField( - onNewRelay = { if (it !in relays) relays.add(it) }, - modifier = Modifier.fillMaxWidth(), + ConcordRelayListEditor( + relays = relays, + onRemove = { relays.remove(it) }, + onAdd = { if (it !in relays) relays.add(it) }, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 837f615dd9..63397ae827 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.conco import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -33,7 +32,6 @@ import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -54,12 +52,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription -import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -152,17 +148,10 @@ fun ConcordEditScreen( title = stringRes(R.string.concord_create_relays), description = stringRes(R.string.concord_edit_relays_desc), ) - relays.forEach { relay -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(relay.displayUrl(), Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - IconButton(onClick = { relays.remove(relay) }) { - SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove)) - } - } - } - RelayUrlEditField( - onNewRelay = { if (it !in relays) relays.add(it) }, - modifier = Modifier.fillMaxWidth(), + ConcordRelayListEditor( + relays = relays, + onRemove = { relays.remove(it) }, + onAdd = { if (it !in relays) relays.add(it) }, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index 0395f31258..67a4928275 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -24,16 +24,20 @@ import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator @@ -52,18 +56,29 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -268,3 +283,77 @@ private fun ConcordBannerHero( } } } + +/** + * The community's bootstrap-relay list editor, shared by the create and edit screens. Each relay is + * shown the same way the Relay Settings screens show them — the relay's NIP-11 favicon, its + * advertised name, and its host — and tapping the row opens the full relay-info page ([Route.RelayInfo]), + * so a community relay is a first-class, inspectable relay rather than a bare URL string. A trailing + * ✕ removes it; the [RelayUrlEditField] below adds one. State is owned by the caller. + */ +@Composable +fun ConcordRelayListEditor( + relays: List, + onRemove: (NormalizedRelayUrl) -> Unit, + onAdd: (NormalizedRelayUrl) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + relays.forEach { relay -> + ConcordRelayRow(relay, { onRemove(relay) }, accountViewModel, nav) + } + RelayUrlEditField( + onNewRelay = onAdd, + modifier = Modifier.fillMaxWidth(), + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConcordRelayRow( + relay: NormalizedRelayUrl, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + // The NIP-11 relay-info doc (icon + display name), fetched + cached exactly like the settings rows. + val relayInfo by loadRelayInfo(relay) + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + RenderRelayIcon( + displayUrl = relayInfo.id ?: relay.displayUrl(), + iconUrl = relayInfo.icon, + loadProfilePicture = accountViewModel.settings.showProfilePictures(), + loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + pingInMs = 0, + iconModifier = MediumRelayIconModifier, + ) + Spacer(Modifier.width(10.dp)) + Column( + Modifier + .weight(1f) + .combinedClickable( + onClick = { nav.nav(Route.RelayInfo(relay.url)) }, + onLongClick = { scope.launch { clipboard.setText(relay.url) } }, + ), + ) { + relayInfo.name?.takeIf { it.isNotBlank() }?.let { name -> + Text(name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Text( + text = relay.displayUrl(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } + IconButton(onClick = onRemove) { + SymbolIcon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove)) + } + } +} From 4ec44ad241546925ad0ca405d63b99509e26345d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 19:38:58 -0400 Subject: [PATCH 110/115] feat(concord): harvest full member roster from bounded channel history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The members roster was a fraction of the real membership (e.g. ~13 vs ~44 on Armada). Concord membership includes every "observed author" (CORD-02 §5 — anyone seen publishing), but the live channel subs only carry the recent tail the relay serves, so most members — who posted outside that tail and never sent a Guestbook Join — never appeared. Add ConcordMemberHarvest: a headless, run-once background sweep mounted by the members screen that pages every folded channel's history back to a bounded window (90 days — tunable; bounds the data pulled onto the device, per the "how far back" limit) in one pooled `fetchAllPagesFromPool`. The wraps ride the app's normal ingest (global CacheClientConnector → concordSessions.ingest), which folds each author into `observedAuthors`, so the roster fills in with no extra plumbing. AUTH is free — the channel stream keys are already registered for these relays. `beginMemberHarvest()` gates it to once per community. Prerequisite fix: `ConcordCommunitySession.ingest` re-decrypted a channel's WHOLE wrap buffer on every incoming message (reprojectChannel), which is O(n²) in the message count — fine for a ~50-wrap live tail but fatal for a history sweep. Split it: a message now projects only its own wrap (O(1)); the re-decrypt-all path stays for a re-fold (where channel keys can change). This also speeds the live path. `ConcordCommunitySessionTest` now asserts the one-wrap-per-message projection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordMemberHarvest.kt | 95 +++++++++++++++++++ .../concord/ConcordMembersScreen.kt | 5 + .../model/concord/ConcordCommunitySession.kt | 28 +++++- .../concord/ConcordCommunitySessionTest.kt | 7 ++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt new file mode 100644 index 0000000000..c68a503873 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMemberHarvest.kt @@ -0,0 +1,95 @@ +/* + * 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.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * How far back the member-roster harvest pages each channel. Concord membership includes every observed + * author (CORD-02 §5), but they only appear by decrypting their messages — so a complete roster needs + * history, not just the live tail. This bounds the history pulled onto the device: members whose last + * post predates the window aren't counted. Tune here to trade completeness for data. + */ +private const val CONCORD_MEMBER_HARVEST_WINDOW_SECS = 90L * 24 * 60 * 60 + +/** + * A headless, run-once background sweep that fills in a Concord community's **full** member roster. + * + * The live channel subscriptions only carry the recent tail the relay serves, so [observedAuthors] + * (CORD-02 §5) sees only recent posters — a fraction of the real membership. This pages every folded + * channel's history back to [CONCORD_MEMBER_HARVEST_WINDOW_SECS] in one pooled fetch. The wraps ride + * the app's normal ingest (the global `CacheClientConnector` → `concordSessions.ingest`), which decrypts + * each and folds its author into `observedAuthors` — so the members screen's count fills in as the sweep + * runs, with no extra plumbing here. `session.beginMemberHarvest()` gates it to once per community. + * + * Mount it from the members screen; it self-cancels with the composition. AUTH is free — the channel + * planes' stream keys are already registered for these relays (same path the live sub uses). + */ +@Composable +fun ConcordMemberHarvest( + communityId: String, + accountViewModel: AccountViewModel, +) { + val account = accountViewModel.account + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val session = remember(communityId, revision) { account.concordSessions.sessionFor(communityId) } + + androidx.compose.runtime.LaunchedEffect(session, revision) { + val s = session ?: return@LaunchedEffect + // Each folded channel's derived plane pubkey is a REQ author. Empty until the Control Plane folds + // its channels — a later revision re-runs this effect, so we harvest as soon as they appear. + val planePks = s.channelAddresses().toList() + if (planePks.isEmpty()) return@LaunchedEffect + if (!s.beginMemberHarvest()) return@LaunchedEffect + + val relays = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.relays + ?.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + .orEmpty() + if (relays.isEmpty()) return@LaunchedEffect + + val filter = + Filter( + kinds = listOf(ConcordStreamEnvelope.KIND_WRAP), + authors = planePks, + since = TimeUtils.now() - CONCORD_MEMBER_HARVEST_WINDOW_SECS, + ) + withContext(Dispatchers.IO) { + runCatching { + // Events land via the global ingest path, so onEvent is a no-op — we only drive the paging. + account.client.fetchAllPagesFromPool(relays.associateWith { listOf(filter) }) { _, _ -> } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 8935f1e3a6..feb28e6c4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -88,6 +88,11 @@ fun ConcordMembersScreen( // screen is opened directly (deep link), not only from the hub. ConcordChannelSubscription(accountViewModel.dataSources().concordChannels, accountViewModel) + // Page every channel's bounded history once so the roster includes observed authors who only posted + // outside the live tail (CORD-02 §5) — the difference between a handful of recent posters and the + // real membership. + ConcordMemberHarvest(communityId, accountViewModel) + // Re-resolve the session as sessions are created/folded (revision-keyed), so a deep link that // lands before the community's session exists still picks it up once it does. val revision by account.concordSessions.revision.collectAsStateWithLifecycle() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index ad29174e4c..6a29fd59cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -140,6 +140,23 @@ class ConcordCommunitySession( */ val observedAuthors: StateFlow> = _observedAuthors + private var memberHarvestStarted = false + + /** + * Returns true exactly once — for the caller that should run the one-shot full-history member-roster + * harvest (page every channel's history back to a bounded window so ingest can fold the older posters + * into [observedAuthors]). Idempotent, so re-opening the members screen never re-pages. + */ + fun beginMemberHarvest(): Boolean = + lock.withLock { + if (memberHarvestStarted) { + false + } else { + memberHarvestStarted = true + true + } + } + // channelIdHex -> (other member pubkey -> createdAt secs of their latest typing heartbeat). private val typingByChannel = HashMap>() private val _typing = MutableStateFlow>>(emptyMap()) @@ -266,7 +283,8 @@ class ConcordCommunitySession( // Project only the newly-arrived wrap — the buffer's earlier wraps were already // emitted when they landed, so re-decrypting the whole history on every message // would be O(history) per message (quadratic over a channel's lifetime). A duplicate - // re-delivery (isNew == false) is a no-op. + // re-delivery (isNew == false) is a no-op. A full-history sweep (member-roster harvest) + // relies on this staying O(1) per wrap. if (isNew) emitChannelRumors(channelIdHex, key, listOf(wrap)) // A chat message lands in the feed via [onRumor] → LocalCache, independent of the // revision; it changes no plane address, so it must NOT bump (see the storm note above). @@ -334,14 +352,18 @@ class ConcordCommunitySession( } } + /** Re-decrypts and re-projects a channel's WHOLE wrap buffer. Only for a re-fold (keys may change). */ private fun reprojectChannel(channelIdHex: HexKey) { val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return emitChannelRumors(channelIdHex, key, wraps) } - /** Decrypt + validate the given [wraps], hand each bound rumor to the sink, and fold its author into - * the observed roster. The sink dedups by rumor id, so re-emitting a wrap is idempotent. */ + /** + * Decrypts + validates [wraps] on [channelIdHex], hands each bound rumor to the sink (which dedups + * by rumor id, so re-emitting is idempotent), and folds their authors into [observedAuthors] — every + * author we decrypt is observably present (CORD-02 §5), a member even without a Guestbook Join. + */ private fun emitChannelRumors( channelIdHex: HexKey, key: GroupKey, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 9477b8cb18..0cb8152a95 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -95,6 +95,13 @@ class ConcordCommunitySessionTest { assertEquals(message.id, reply.tags.first { it[0] == "e" }[1]) assertTrue(ChannelChat.isBoundTo(reply, community.generalChannelIdHex, community.rootEpoch)) + // Each incoming wrap is projected to the sink exactly ONCE (message + reaction + reply = 3), + // never by re-decrypting the whole channel buffer per message — the O(1) ingest path that + // keeps a full-history member harvest from being O(n²). + assertEquals(3, captured.size) + // Both channel authors observed (owner posted all three) — folded into the roster. + assertEquals(setOf(owner.pubKey.lowercase()), session.observedAuthors.value) + // A stray wrap from a different community is ignored. val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first())) From 08c1d1d5395b520bd17e94f63c6caeb85c2fa041 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 20:56:00 -0400 Subject: [PATCH 111/115] fix(concord): show the quoted parent on kind-9 chat replies in NoteCompose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Concord (and MLS/WhiteNoise) chat reply is a kind-9 ChatEvent that carries its reply target as a NIP-18 `q` (or NIP-10 `e`) tag, not a NIP-10 thread — so it isn't a BaseThreadedEvent and RenderTextEvent's reply-to preview never fires for it. On the chat feed the preview is drawn by chat-only code, but everywhere else NoteCompose routes kind-9 through RenderChat, which rendered only the content and never `note.replyTo`. Result: on the Notifications tab a Concord reply showed no quoted parent (no border) — most visibly when replying to an image, whose target is likewise a kind-9. RenderChat now takes unPackReply and, when FULL and not makeItShort, renders ReplyNoteComposition(note.replyTo.lastOrNull()) like the threaded path does, skipping it when the parent is already cited inline (`nostr:...`) so an MLS-style quote isn't drawn twice. NoteCompose forwards unPackReply; the thread view passes NONE since its structure already shows the parent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/ui/note/NoteCompose.kt | 1 + .../amethyst/ui/note/types/Chat.kt | 26 +++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 1 + 3 files changed, 28 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 940f01cd13..5ae73506f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -1455,6 +1455,7 @@ private fun RenderNoteRow( makeItShort, canPreview, quotesLeft, + unPackReply, backgroundColor, accountViewModel, nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt index e51612e3a7..53c00eaa9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chat.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -35,10 +36,13 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip10Notes.BaseNoteEvent @Composable fun RenderChat( @@ -46,6 +50,7 @@ fun RenderChat( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, + unPackReply: ReplyRenderType, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, @@ -65,6 +70,27 @@ fun RenderChat( overflow = TextOverflow.Ellipsis, ) } else { + // A kind-9 chat message carries its reply target as a NIP-18 `q` (or NIP-10 `e`) + // tag, NOT as a NIP-10 thread — so it's not a BaseThreadedEvent and RenderTextEvent's + // reply-to preview never fires for it. Render the quoted parent here so a Concord/MLS + // chat reply shows what it's replying to wherever NoteCompose draws it (Notifications + // tab, feed, threads) — the chat feed has its own reply-row and passes NONE. + if (unPackReply == ReplyRenderType.FULL && !makeItShort) { + val replyingDirectlyTo = + remember(note) { + // Skip the preview when the parent is already cited inline (`nostr:...`) in the + // message — quotesLeft renders it at that spot, so a top preview would duplicate + // it. Happens with MLS/WhiteNoise quotes; Concord `q` replies aren't cited inline. + note.replyTo?.lastOrNull()?.takeUnless { parent -> + (noteEvent as? BaseNoteEvent)?.findCitations()?.contains(parent.idHex) == true + } + } + if (replyingDirectlyTo != null) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + val callbackUri = remember(note) { note.toNostrUri() } SensitivityWarning( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 51993a25a9..e0e96e33f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -1042,6 +1042,7 @@ private fun FullBleedNoteCompose( makeItShort = false, canPreview = canPreview, quotesLeft = 3, + unPackReply = ReplyRenderType.NONE, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav, From 5b54eb88a516da4e6570c232d8c9fa947dad8048 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 21:51:47 -0400 Subject: [PATCH 112/115] fix(concord): notify on reactions to my Concord messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationFeedFilter derived `isConcord` from the note's own gatherers (is it in a joined ConcordChannel), but LocalCache.consumeConcordRumor only attaches kind-9 messages and kind-1111 replies to the channel — never a kind-7 reaction. So a reaction's `isConcord` was always false, it didn't bypass the follow filter, and since a fellow member usually isn't a follow it was dropped in Curated/Selected mode. Recognize a reaction/repost as Concord through its TARGET instead: if `replyTo.lastOrNull()` is a message in a community I've joined, it bypasses the follow filter exactly like a reply. Relevance is still the existing p-tag gate, so only reactions that actually tag me notify (a well-formed NIP-25 kind-7 p-tags the reacted author, which is what our own ChannelChat.reaction writes). The "Messages in notifications" toggle now gates only Concord messages, not reactions — a like isn't a message, so it follows the same rule as any other reaction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dal/NotificationFeedFilter.kt | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 5618fbcaf1..a75a45fc40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -449,14 +449,30 @@ class NotificationFeedFilter( // gatherer reference from every account/community that ever touched them, so require the // community to be one THIS account has currently joined (mirrors the Marmot check above) — // otherwise a note from a prior account or a left community would leak onto Notifications. - val isConcord = - it.inGatherers?.any { g -> + fun Note?.inJoinedConcordCommunity() = + this?.inGatherers?.any { g -> g is ConcordChannel && account.concordSessions.sessionFor(g.channelId.communityId) != null } == true - // Concord is a messaging feature, so honor the same "Messages in notifications" toggle that - // silences DMs and Marmot groups above. - if (isConcord && !showMessages) return false + val isConcordMessage = it.inJoinedConcordCommunity() + + // A like/repost is NOT itself attached to the channel gatherer — only chat messages/replies are + // (see LocalCache.consumeConcordRumor) — so `inGatherers` never flags it as Concord, and its + // author (a fellow member) usually isn't a follow, so it falls through the follow filter and is + // dropped. Recognize it through its TARGET: a reaction/repost pointing at a message in a + // community I've joined is a Concord reaction, and bypasses the follow filter like a reply does. + // Relevance (does it target ME) is still enforced below by the p-tag gate — a well-formed kind-7 + // p-tags the reacted author (NIP-25), which is exactly what our own ChannelChat.reaction writes. + val isConcordReaction = + (noteEvent is ReactionEvent || noteEvent is RepostEvent || noteEvent is GenericRepostEvent) && + it.replyTo?.lastOrNull().inJoinedConcordCommunity() + + val isConcord = isConcordMessage || isConcordReaction + + // Concord CHAT (a message/reply) honors the "Messages in notifications" toggle that silences DMs + // and Marmot groups above. A reaction isn't a message — regular reactions ignore that toggle, so + // Concord reactions do too (only isConcordMessage is gated). + if (isConcordMessage && !showMessages) return false // Global keeps every event that p-tags the user; Selected (and the // follow/list modes) also applies the per-kind relevance heuristics. From edbc91094100698962ba3e5f3d309d94820d630a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 21:52:04 -0400 Subject: [PATCH 113/115] fix(concord): upload community icon/banner off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking a community icon or banner always failed with "Failed to upload media". The upload was launched from the form's rememberCoroutineScope() (the Compose Main dispatcher), so the very first pipeline step — MediaCompressor.compress — hit Amethyst's checkNotInMainThread() guard and threw OnMainThreadException before any bytes left the device. Run ConcordImageUploader.uploadEncrypted inside withContext(Dispatchers.IO) so the whole compress → strip → AES-GCM-encrypt → Blossom pipeline is off-main; the Compose state write stays on the launching (Main) scope. Also stop the form's catch from swallowing the real cause: surface the actual exception message in the toast (falling back to the generic string only when it has none), log it, and rethrow CancellationException instead of eating it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordImageUploader.kt | 82 +++++++++++-------- .../concord/ConcordMetadataForm.kt | 14 +++- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt index 6ec70492a0..89abba6951 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -33,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Authors a CORD-02 §6 encrypted community image and returns the [ImagePointer] to seal in the @@ -49,47 +51,55 @@ import com.vitorpamplona.quartz.utils.ciphers.AESGCM class ConcordImageUploader( private val account: Account, ) { - /** Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. */ + /** + * Compresses, strips, AES-256-GCM-encrypts and uploads the picked [uri], returning its pointer. + * + * Runs on [Dispatchers.IO]: the compression/upload pipeline asserts it is off the main thread + * ([MediaCompressor] calls `checkNotInMainThread`), and callers launch this from a Compose + * `rememberCoroutineScope()`, which is Main-dispatched — so without this switch the first step + * throws before any bytes leave the device. + */ suspend fun uploadEncrypted( uri: Uri, context: Context, - ): ImagePointer { - // Fresh random key + nonce per image; we hold onto them to build the pointer below since the - // orchestrator only surfaces the ciphertext URL, not the cipher it was handed. - val cipher = AESGCM() + ): ImagePointer = + withContext(Dispatchers.IO) { + // Fresh random key + nonce per image; we hold onto them to build the pointer below since the + // orchestrator only surfaces the ciphertext URL, not the cipher it was handed. + val cipher = AESGCM() - val finalState = - UploadOrchestrator().uploadEncrypted( - uri = uri, - mimeType = context.contentResolver.getType(uri), - alt = null, - contentWarningReason = null, - compressionQuality = CompressorQuality.MEDIUM, - encrypt = cipher, - server = resolveBlossomServer(), - account = account, - context = context, + val finalState = + UploadOrchestrator().uploadEncrypted( + uri = uri, + mimeType = context.contentResolver.getType(uri), + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.MEDIUM, + encrypt = cipher, + server = resolveBlossomServer(), + account = account, + context = context, + ) + + val result = + when (finalState) { + is UploadingState.Finished -> finalState.result + is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params)) + } + + val server = + result as? UploadOrchestrator.OrchestratorResult.ServerResult + ?: throw IllegalStateException("Encrypted community image upload did not return a server URL") + + ImagePointer( + url = server.url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + // hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it + // after decrypting, so it must match what was actually encrypted, not the original file. + hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"), ) - - val result = - when (finalState) { - is UploadingState.Finished -> finalState.result - is UploadingState.Error -> throw IllegalStateException(stringRes(context, finalState.errorResource, *finalState.params)) - } - - val server = - result as? UploadOrchestrator.OrchestratorResult.ServerResult - ?: throw IllegalStateException("Encrypted community image upload did not return a server URL") - - return ImagePointer( - url = server.url, - key = cipher.keyBytes.toHexKey(), - nonce = cipher.nonce.toHexKey(), - // hash is over the *plaintext* (post-compression/strip) bytes — the read path verifies it - // after decrypting, so it must match what was actually encrypted, not the original file. - hash = server.hashBeforeEncryption ?: throw IllegalStateException("Upload pipeline did not report the plaintext hash"), - ) - } + } /** The account's first configured Blossom server, wrapped as a [ServerName], else the default. */ private fun resolveBlossomServer(): ServerName { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index 67a4928275..c70b4b3f57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -79,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon @@ -157,8 +159,12 @@ private fun ConcordIconHero( scope.launch { try { icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show() + Log.w("ConcordImageUpload", "Community icon upload failed", e) + val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details) + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() } finally { uploading = false } @@ -224,8 +230,12 @@ private fun ConcordBannerHero( scope.launch { try { banner.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show() + Log.w("ConcordImageUpload", "Community banner upload failed", e) + val msg = e.message?.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.failed_to_upload_media_no_details) + Toast.makeText(context, msg, Toast.LENGTH_LONG).show() } finally { uploading = false } From 984cafd1fbdf220aa1ceec360c9f786c29a519bf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 21:52:19 -0400 Subject: [PATCH 114/115] fix(concord): community title fallback + round channel-create FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel-list screen titled itself `state.metadata.name ?: app_name`, so before the metadata edition folded it showed the app's own name — "Amy Debug" in a debug build. Prefer the folded name, then the stored community name from the list entry (always present from the join/create, and what shows everywhere else); the app-name fallback is now effectively unreachable. The channel-create FAB used Material 3's default rounded-square shape; every other FAB in the app is a circle. Set shape = CircleShape to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordChannelListScreen.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 959efe2288..1ec3fcc42f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -155,7 +156,17 @@ fun ConcordChannelListScreen( Scaffold( topBar = { TopAppBar( - title = { Text(state?.metadata?.name ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name), maxLines = 1) }, + title = { + // Prefer the folded metadata name, then the stored community name from the list + // entry (always present from the join/create — this is what shows everywhere else). + // Fall back to the app name only if neither exists (should be unreachable), never as + // the normal "metadata hasn't folded yet" placeholder — that showed "Amy Debug". + val title = + state?.metadata?.name + ?: session?.entry?.name?.ifBlank { null } + ?: stringRes(com.vitorpamplona.amethyst.R.string.app_name) + Text(title, maxLines = 1) + }, navigationIcon = { IconButton(onClick = { nav.popBack() }) { SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.back)) @@ -198,7 +209,10 @@ fun ConcordChannelListScreen( }, floatingActionButton = { if (canManageChannels) { - FloatingActionButton(onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") }) { + FloatingActionButton( + onClick = { channelEditor = ConcordChannelEditor(channelIdHex = null, initialName = "") }, + shape = CircleShape, + ) { SymbolIcon(symbol = MaterialSymbols.Add, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_channel_create)) } } From b0d18b2983aadff0534dbf990077d1f08fddffd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 02:29:18 +0000 Subject: [PATCH 115/115] fix(concord): import kotlin.jvm.JvmInline so ConcordPermissions compiles on iOS `kotlin.jvm.*` is a default import on the JVM target but not on Kotlin/Native, so `@JvmInline` on the ConcordPermissions value class resolved on JVM/Android yet failed the iOS (compileKotlinIosSimulatorArm64) build with "Unresolved reference 'JvmInline'". Add the explicit import; JVM is unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../quartz/concord/cord04Roles/ConcordPermissions.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt index 8250599d78..13782071cf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordPermissions.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import kotlin.jvm.JvmInline + /** * A member's Concord permission set (CORD-04): a `u64` bitfield of frozen bit * positions. On the wire it is encoded as a **decimal string** (not a JSON