From ade45c33261a65efe597576ba9ed1288b9adf81e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:24:59 +0000 Subject: [PATCH 001/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] =?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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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/206] 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 ac98036a8e71fadc62be5699bb775ddef1b82b81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:04:28 +0000 Subject: [PATCH 052/206] perf(pow): mine NIP-13 proof of work on all cores and drop per-hash allocations The miner enumerates the nonce space deterministically (the random base is overwritten before the first hash), so naively racing N copies of the search duplicates the exact same candidate sequence N times. PoWMiner.mine() now races workers over disjoint slices instead: each worker's nonce carries a distinct fixed prefix while only the bytes after it are enumerated, making the aggregate hash rate scale with cores (~3.8x on a 4-core box). The hot loop also switches from sha256() to sha256Into() with a reused 32-byte buffer, so hashing no longer allocates per attempt. amy wiring: - `pow mine` and `post --pow` mine on all cores by default; `pow mine --threads N` overrides. - `pow bench` measures the all-cores rate (what mining now uses, also the basis for expected_seconds) alongside a new hashes_per_second_single_core. - PoWEstimator benchmarks with sha256Into to match the miner, and gains a workers overload that prices in cross-core contention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM --- cli/README.md | 6 +- .../amethyst/cli/commands/PostCommand.kt | 5 +- .../amethyst/cli/commands/PowCommands.kt | 30 +++-- .../commons/service/pow/PoWEstimator.kt | 29 ++++- .../quartz/nip13Pow/miner/PoWMiner.kt | 114 +++++++++++++++++- .../quartz/nip13Pow/PoWMinerParallelTest.kt | 91 ++++++++++++++ 6 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt diff --git a/cli/README.md b/cli/README.md index 0ded3d65d8..928009c996 100644 --- a/cli/README.md +++ b/cli/README.md @@ -216,8 +216,8 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy encode naddr --kind N --pubkey HEX --identifier D [--relay URL[,URL…]]` | Encode an addressable-event (`a` tag) pointer. | | `amy verify [EVENT-JSON]` | Check an event's id hash and signature. Reads stdin when the argument is omitted or `-`. Reports `id_ok` + `signature_ok` separately. | | `amy pow check EVENT-JSON\|-` | NIP-13 difficulty of a signed event: `actual_bits`, `committed_target`, `has_commitment`, and `effective_pow` (capped at the commitment so lucky low-target spam doesn't over-count), plus `valid` (id + signature). | -| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Exit 124 on timeout. | -| `amy pow bench` | Benchmark this machine's hash rate and print expected mining time at 16/20/24/28 bits. | +| `amy pow mine --target N [--pubkey HEX] [--timeout SECS] [--threads N] TEMPLATE-JSON\|-` | Mine an **unsigned** template to N leading zero bits and print it back with the nonce tag. Ids don't commit to signatures, so amy can mine on behalf of any pubkey (NIP-13 delegated PoW); defaults to the active account. Mines on all cores by default (`--threads` to override). Exit 124 on timeout. | +| `amy pow bench` | Benchmark this machine's hash rate — `hashes_per_second` is the all-cores rate `pow mine` uses by default, `hashes_per_second_single_core` the one-thread rate — and print expected mining time at 16/20/24/28 bits. | | `amy key generate` | Mint a fresh keypair (`nsec` + `npub` + hex). Does not persist — use `init`/`login` for that. | | `amy key public NSEC\|HEX` | Derive the public key from a secret key. | | `amy key encrypt NSEC\|HEX --password X` | NIP-49 encrypt a secret key to an `ncryptsec1…`. | @@ -384,7 +384,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). | +| `amy notes post TEXT [--relay URL] [--pow BITS [--pow-timeout SECS]]` | Publish a kind:1 short text note; `--pow` mines a NIP-13 proof of work into it first, using all cores (blocks while mining, exit 124 on timeout with nothing published; `--json` adds `pow`, `pow_target`, `pow_millis`). | | `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt index b94ead611d..d26283d735 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PostCommand.kt @@ -81,13 +81,14 @@ object PostCommand { var powMillis: Long? = null val readyToSign = if (powTarget != null) { - System.err.println("mining $powTarget bits…") + val threads = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + System.err.println("mining $powTarget bits… ($threads threads)") val deadlineNanos = powTimeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } val startedAt = System.nanoTime() val mined = try { withContext(Dispatchers.Default) { - PoWMiner.run(template, ctx.signer.pubKey, powTarget) { + PoWMiner.mine(template, ctx.signer.pubKey, powTarget, threads) { deadlineNanos == null || System.nanoTime() < deadlineNanos } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt index bc6a1e73bb..293b3d7428 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PowCommands.kt @@ -108,13 +108,18 @@ object PowCommands { rest: Array, ): Int { val args = Args(rest) - val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] " + val usage = "pow mine --target N [--pubkey HEX] [--timeout SECS] [--threads N] " val target = args.flags["target"]?.toIntOrNull() ?: return Output.error("bad_args", usage) if (target < 1 || target > MAX_DIFFICULTY) { return Output.error("bad_args", "--target must be between 1 and $MAX_DIFFICULTY") } + val threads = args.intFlag("threads", defaultThreads()) + if (threads < 1) { + return Output.error("bad_args", "--threads must be >= 1") + } + val json = readPayload(args.positional.toTypedArray()) ?: return Output.error("bad_args", usage) val template = try { @@ -142,13 +147,13 @@ object PowCommands { val timeoutSec = args.flags["timeout"]?.toLongOrNull() val deadlineNanos = timeoutSec?.let { System.nanoTime() + it * 1_000_000_000L } - System.err.println("mining $target bits for ${pubKey.take(8)}…") + System.err.println("mining $target bits for ${pubKey.take(8)}… ($threads threads)") val startedAt = System.nanoTime() val mined = try { withContext(Dispatchers.Default) { - PoWMiner.run(template, pubKey, target) { + PoWMiner.mine(template, pubKey, target, threads) { deadlineNanos == null || System.nanoTime() < deadlineNanos } } @@ -176,27 +181,38 @@ object PowCommands { "pow" to PoWRankEvaluator.calculatePowRankOf(id), "pow_target" to target, "pow_millis" to elapsedMs, + "threads" to threads, "template_json" to mined.toJson(), ), ) return 0 } - /** `amy pow bench` — hash rate + expected mining time per common target. */ + /** + * `amy pow bench` — single-core and all-cores hash rate, plus expected + * mining time per common target at the all-cores rate (what `pow mine` + * uses by default). + */ private suspend fun bench(): Int { - val rate = PoWEstimator.hashesPerSecond() + val threads = defaultThreads() + val singleRate = PoWEstimator.hashesPerSecond() + val parallelRate = PoWEstimator.hashesPerSecond(threads) Output.emit( mapOf( - "hashes_per_second" to rate.roundToLong(), + "hashes_per_second" to parallelRate.roundToLong(), + "hashes_per_second_single_core" to singleRate.roundToLong(), + "threads" to threads, "expected_seconds" to listOf(16, 20, 24, 28).associate { bits -> - bits.toString() to PoWEstimator.estimateSeconds(bits, rate) + bits.toString() to PoWEstimator.estimateSeconds(bits, parallelRate) }, ), ) return 0 } + private fun defaultThreads(): Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + private fun readPayload(rest: Array): String? { val arg = rest.firstOrNull() ?: return null val payload = if (arg == "-") System.`in`.readBytes().decodeToString() else arg diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt index e12843c5fc..1ed00fc1ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.commons.service.pow -import com.vitorpamplona.quartz.utils.sha256.sha256 +import com.vitorpamplona.quartz.utils.sha256.sha256Into import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -61,6 +63,25 @@ object PoWEstimator { } } + /** + * Aggregate hash rate with [workers] concurrent miners — what + * [com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner.mine] achieves when + * racing that many workers. Measured live (not cached): each worker runs + * its own ~250 ms benchmark loop concurrently and the rates are summed, + * so contention between cores is priced in. + */ + suspend fun hashesPerSecond( + workers: Int, + dispatcher: CoroutineDispatcher = Dispatchers.Default, + ): Double = + if (workers <= 1) { + hashesPerSecond(dispatcher) + } else { + withContext(dispatcher) { + List(workers) { async { benchmark() } }.awaitAll().sum() + } + } + fun estimateSeconds( difficulty: Int, hashesPerSecond: Double, @@ -68,14 +89,16 @@ object PoWEstimator { private fun benchmark(): Double { val payload = ByteArray(PAYLOAD_BYTES) { (it % 251).toByte() } + // same allocation-free hashing the miner's hot loop uses + val out = ByteArray(32) // warm up JIT/caches so the measured window reflects steady state - repeat(3 * BATCH) { sha256(payload) } + repeat(3 * BATCH) { sha256Into(out, payload, payload.size) } val mark = TimeSource.Monotonic.markNow() var count = 0L while (mark.elapsedNow() < BENCH_DURATION) { - repeat(BATCH) { sha256(payload) } + repeat(BATCH) { sha256Into(out, payload, payload.size) } count += BATCH } return count / mark.elapsedNow().toDouble(DurationUnit.SECONDS) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt index 280d38309f..cb47baebf2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/miner/PoWMiner.kt @@ -25,19 +25,30 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag -import com.vitorpamplona.quartz.utils.sha256.sha256 +import com.vitorpamplona.quartz.utils.sha256.sha256Into +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException class PoWMiner( val buffer: MiningBuffer, val desiredPoW: Int, val isActive: () -> Boolean = { true }, + // first nonce byte the search is allowed to change; bytes between + // nonceStarts and this index stay fixed (a parallel worker's prefix). + val searchFrom: Int = buffer.nonceStarts, ) { val emptyBytesForDesiredPoW = desiredPoW / 8 - fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(sha256(byteArray), desiredPoW, emptyBytesForDesiredPoW) + // sha256Into writes every attempt's hash here instead of allocating a fresh + // 32-byte array per hash, keeping the hot loop allocation-free. + private val hashOut = ByteArray(32) - fun run() = runDigit(buffer.nonceStarts) + fun reachedDesiredPoW(byteArray: ByteArray) = PoWRankEvaluator.atLeastPowRank(sha256Into(hashOut, byteArray, byteArray.size), desiredPoW, emptyBytesForDesiredPoW) + + fun run() = runDigit(searchFrom) private fun runDigit(index: Int): Boolean { // checks once every VALID_BYTES.size^2 hashes: cheap enough to not slow @@ -74,6 +85,10 @@ class PoWMiner( * The miner creates a stringified json template and changes the nonce directly in the UTF-8 ByteArray representation * to avoid having to recompute the json objects and stringify it. * + * The search enumerates the nonce space deterministically ([VALID_BYTES] + * in order at every position), so for a given template and pubkey every + * call hashes the same sequence of candidates. + * * [isActive] is polled while mining; returning false aborts the search with a * [CancellationException] so callers can cancel long-running jobs cooperatively. */ @@ -82,6 +97,19 @@ class PoWMiner( pubKey: HexKey, desiredPoW: Int, isActive: () -> Boolean = { true }, + ): EventTemplate = search(template, pubKey, desiredPoW, isActive, "") + + /** + * [noncePrefix] is kept verbatim at the front of the nonce while only the + * bytes after it are enumerated — parallel workers get distinct prefixes + * so their search spaces never overlap. + */ + private fun search( + template: EventTemplate, + pubKey: HexKey, + desiredPoW: Int, + isActive: () -> Boolean, + noncePrefix: String, ): EventTemplate { // sha256 ids have 256 bits; anything outside would index past the // hash (or never terminate) deep inside the hot loop. @@ -90,7 +118,7 @@ class PoWMiner( var nextSize = STARTING_NONCE_SIZE do { - val initialNonce = randomBase(nextSize) + val initialNonce = noncePrefix + randomBase(nextSize) val bytes = EventHasherSerializer @@ -104,9 +132,9 @@ class PoWMiner( val startIndex = bytes.indexOf(initialNonce.encodeToByteArray()) - val buffer = MiningBuffer(bytes, startIndex, startIndex + nextSize) + val buffer = MiningBuffer(bytes, startIndex, startIndex + initialNonce.length) - if (PoWMiner(buffer, desiredPoW, isActive).run()) { + if (PoWMiner(buffer, desiredPoW, isActive, startIndex + noncePrefix.length).run()) { return EventTemplate( template.createdAt, template.kind, @@ -120,5 +148,79 @@ class PoWMiner( throw RuntimeException("Could not find PoW") } + + /** + * Distinct fixed nonce prefix for each racing worker, encoding the worker + * index in base-[VALID_CHARS].size at the smallest width that fits + * [workers] — one char up to 73 workers. + */ + private fun workerPrefix( + worker: Int, + workers: Int, + ): String { + var width = 1 + var capacity = VALID_CHARS.size + while (capacity < workers) { + width++ + capacity *= VALID_CHARS.size + } + + var remaining = worker + val prefix = CharArray(width) + for (i in width - 1 downTo 0) { + prefix[i] = VALID_CHARS[remaining % VALID_CHARS.size] + remaining /= VALID_CHARS.size + } + return prefix.concatToString() + } + + /** + * Multi-core variant of [run]: races [workers] searches over disjoint + * slices of the nonce space (each worker's nonce carries a distinct fixed + * prefix) and returns the first template to reach [desiredPoW]. Workers + * share nothing but the finish flag, so the hash rate scales roughly + * linearly with cores. + * + * Throws the same [CancellationException] as [run] when [isActive] flips + * false before a nonce is found. + */ + suspend fun mine( + template: EventTemplate, + pubKey: HexKey, + desiredPoW: Int, + workers: Int = 1, + isActive: () -> Boolean = { true }, + ): EventTemplate { + require(workers >= 1) { "workers must be >= 1, was $workers" } + if (workers == 1) return run(template, pubKey, desiredPoW, isActive) + + return coroutineScope { + val winner = CompletableDeferred>() + val race = + launch { + repeat(workers) { worker -> + launch(Dispatchers.Default) { + winner.complete( + search(template, pubKey, desiredPoW, { + isActive() && !winner.isCompleted + }, workerPrefix(worker, workers)), + ) + } + } + } + // every worker aborting without a win means the caller's isActive + // flipped: rethrow the CancellationException the workers swallowed + // (a losing worker's complete() is a no-op, so this can't clobber + // a real result). + race.invokeOnCompletion { + winner.completeExceptionally(CancellationException("PoW mining was cancelled")) + } + try { + winner.await() + } finally { + race.cancel() + } + } + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt new file mode 100644 index 0000000000..d877a481ec --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip13Pow/PoWMinerParallelTest.kt @@ -0,0 +1,91 @@ +/* + * 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.nip13Pow + +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasherSerializer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner +import com.vitorpamplona.quartz.nip13Pow.miner.PoWRankEvaluator +import com.vitorpamplona.quartz.nip13Pow.tags.PoWTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.test.runTest +import kotlin.coroutines.cancellation.CancellationException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +class PoWMinerParallelTest { + val pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + + val baseTemplate = + EventTemplate( + 1683596206, + TextNoteEvent.KIND, + emptyArray(), + "A note to mine", + ) + + @Test + fun parallelMinerFindsAValidPoW() = + runTest { + val desiredPoW = 12 + val mined = PoWMiner.mine(baseTemplate, pubKey, desiredPoW, workers = 4) + + val powTag = mined.tags.firstNotNullOfOrNull { PoWTag.parse(it) } + assertNotNull(powTag, "mined template must carry a nonce tag") + + val id = + sha256( + EventHasherSerializer.fastMakeJsonForId( + pubKey = pubKey, + createdAt = mined.createdAt, + kind = mined.kind, + tags = mined.tags, + content = mined.content, + ), + ) + + assertTrue( + PoWRankEvaluator.atLeastPowRank(id, desiredPoW, desiredPoW / 8), + "mined id must reach the desired PoW", + ) + } + + @Test + fun cancellationAbortsAllParallelWorkers() = + runTest { + val start = TimeSource.Monotonic.markNow() + // 256 bits never completes; only the isActive deadline can end the run. + assertFailsWith { + PoWMiner.mine(baseTemplate, pubKey, 256, workers = 4) { + start.elapsedNow() < 150.milliseconds + } + } + assertTrue( + start.elapsedNow() < 5_000.milliseconds, + "all workers must stop shortly after isActive flips, took ${start.elapsedNow()}", + ) + } +} From c2cbd602bc76d70c622941a53a305558fab6e17a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 04:05:07 +0000 Subject: [PATCH 053/206] 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 054/206] 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 055/206] 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 cf95b07855465939c1e76af099d1c3fd3fde792a Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 11 Jul 2026 11:10:13 +0100 Subject: [PATCH 056/206] fix: document intentionally empty default no-op bodies --- .../commons/relayauth/RelayAuthPermissionStore.kt | 8 ++++++-- .../relay/client/listeners/RelayConnectionListener.kt | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt index 3b33a0666f..68dbb5d6cf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthPermissionStore.kt @@ -50,7 +50,9 @@ interface RelayAuthPermissionStore { suspend fun recordUse( relayUrl: String, additions: Map>, - ) {} + ) { + // no-op by default: stores that don't track rationale ignore recorded uses. + } /** The accumulated grant rationale for [relayUrl] (purpose → counterparty pubkeys). */ suspend fun loadRationale(relayUrl: String): Map> = emptyMap() @@ -59,7 +61,9 @@ interface RelayAuthPermissionStore { suspend fun allRationales(): Map>> = emptyMap() /** Forgets the accumulated grant rationale for [relayUrl] (does not touch the ALLOW/DENY override). */ - suspend fun clearRationale(relayUrl: String) {} + suspend fun clearRationale(relayUrl: String) { + // no-op by default: stores that don't track rationale have nothing to clear. + } /** Epoch-second timestamp of the last time each relay was authenticated with (for display). */ suspend fun allLastUsed(): Map = emptyMap() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt index c3e4be1810..feefbb532f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/listeners/RelayConnectionListener.kt @@ -80,7 +80,9 @@ interface RelayConnectionListener { fun onEventGaveUp( relay: IRelayClient, event: Event, - ) {} + ) { + // no-op by default: listeners that don't surface delivery failures ignore it. + } } object EmptyConnectionListener : RelayConnectionListener From b8570797fe12f860d0d8c953a720f41065073e42 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 11 Jul 2026 11:33:51 +0100 Subject: [PATCH 057/206] refactor: extract no-reachability-cache flag name into a constant --- .../amethyst/cli/commands/GrapeRankCommand.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index cd9faa1cc9..243b88264d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -137,6 +137,10 @@ object GrapeRankCommand { private const val PROBE_TIMEOUT_MS = 2000 + // args.bool on a mistyped flag silently returns false, so the one flag read from + // three different functions goes through a compile-time-checked name. + private const val NO_REACHABILITY_CACHE_FLAG = "no-reachability-cache" + // The probe does BLOCKING DNS + TCP connect, and dead-domain DNS lookups can hang // far past the connect timeout. On the shared Dispatchers.IO those hanging lookups // starve the crawl's own IO — measured +462s on the finishing drain at hop-3. Run @@ -452,7 +456,7 @@ object GrapeRankCommand { // parallel storm at crawl start so the connect wait is paid once, up front. // The crawl's own final live/dead set is flushed back by the caller. val reachability = - if (args.bool("no-reachability-cache")) null else ctx.reachability.snapshot() + if (args.bool(NO_REACHABILITY_CACHE_FLAG)) null else ctx.reachability.snapshot() val knownDead = reachability?.dead ?: emptySet() // Mass pre-connect sizing: every warm socket is one FD, so the default is // derived from the process's ulimit (--preconnect-cap to override, @@ -529,7 +533,7 @@ object GrapeRankCommand { args: Args, stats: GrapeRankCrawler.Stats, ) { - if (args.bool("no-reachability-cache")) return + if (args.bool(NO_REACHABILITY_CACHE_FLAG)) return runCatching { ctx.reachability.record(reachable = stats.liveRelays, dead = stats.deadRelays) System.err.println( @@ -699,7 +703,7 @@ object GrapeRankCommand { // Live author-advertised relays are always synced (--no-reachability-cache // to reconcile every relay regardless). val knownDead = - if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead + if (args.bool(NO_REACHABILITY_CACHE_FLAG)) emptySet() else ctx.reachability.snapshot().dead val updater = GrapeRankUpdater( From cc275ce62d216fd8ee48339a3c6ece0fd6f428c1 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:47:38 +0000 Subject: [PATCH 058/206] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 8 + .../src/main/res/values-hu-rHU/strings.xml | 223 +++++++++++++++++- .../src/main/res/values-pl-rPL/strings.xml | 116 +++++++++ .../src/main/res/values-zh-rCN/strings.xml | 196 ++++++++++++++- docs/changelog/translators.json | 24 +- 5 files changed, 551 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 94f6facc75..8e290818cf 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -783,10 +783,18 @@ पुनःप्रसारक प्रमाणीकरण प्रमाण प्रमाणीकरण पुनःप्रसारक हस्ताक्षरण सत्यापन निप॰४२ परिचय वैश्विक नीति + किसमें प्रवेशांकन करना है सर्वदा प्रमाणीकरण प्रमाणीकरण चुनौतियों को हस्ताक्षर प्रत्येक पुनःप्रसारक के लिए जो इसका अनुरोध करता है प्रमाणीकरण कभी ना करें प्रमाणीकरण चुनौतियों की उपेक्षा सभी पुनःप्रसारकों के प्रति + अनुकूलित + स्पष्ट चयन करें किन पुनःप्रसारकों में प्रवेशांकन करना चाहिए। नीचे जिसको अनुमति नहीं दी गई उन सब के विषय में आप पूछे जाएँगे। + मेरे पुनःप्रसारक तथा आयोजन स्थल + प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा सीधे प्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं। + मेरे द्वारा अनुचरित लोगों के पत्र पढें + अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें उनके पत्र प्राप्त करने के लिए। + मेरे द्वारा अनुचरित लोगों को सन्देश भेजें प्रति पुनःप्रसारक आवरक आदेश diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ba54b568d3..ef95c83d52 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -782,15 +782,52 @@ Átjátszó-hitelesítés hitelesítés, átjátszó, aláírás, ellenőrzés, nip-42, azonosság - Globális szabály + Hitelesítéskor + Mibe jelentkezzen be Hitelesítés kérése mindig Minden olyan átjátszó esetében alá kell írni az azonosítási kihívásokat, amely ezt kéri Soha ne végezzen hitelesítést Az összes átjátszó hitelesítési kérésének figyelmen kívül hagyása + Egyéni + Válassza ki pontosan, hogy mely átjátszókba jelentkezik be. A lent nem engedélyezett dolgokról kérdést fog kapni. + Saját átjátszók és helyszínek + Bejelentkezés a saját átjátszóiba, valamint azokba a nyilvános csevegésekbe, közösségekbe és élő közvetítésekbe, amelyekhez csatlakozott vagy amelyeket kedvelt. + Követett személyek bejegyzéseinek olvasása + Bejelentkezés egy követett személy átjátszóiba a bejegyzéseinek letöltéséhez. + Üzenet küldése a követett személyeknek + Bejelentkezés egy követett személy átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez. + Üzenet küldése bárkinek + Bejelentkezés idegenek átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez. Alapértelmezés szerint ki van kapcsolva, ezért minden alkalommal kérdést fog kapni. + Megerősíti a személyazonosságát ennek az átjátszónak? + Ez az átjátszó először meg szeretné erősíteni, hogy tényleg Ön az. Az üzemeltetője látni fogja, hogy melyik fiókot használja. + Elküldi az üzenetet neki: %1$s? + Értesíti őt: %1$s? + Betölti a bejegyzéseket tőle: %1$s? + Közzéteszi itt: %1$s? + Megnyitja ezt: %1$s? + Ha nem teszi meg, az üzenete nem lesz kézbesítve neki: %1$s. + Ha nem teszi meg, %1$s nem kap erről értesítést. + Ha nem teszi meg, nem fogja itt látni a bejegyzéseket tőle: %1$s. + Ha nem teszi meg, az üzenete nem lesz közzétéve itt: %1$s. + Ha nem teszi meg, nem fogja itt látni ezt: %1$s. + %1$s és mások + Privát üzenet küldése neki: + Értesítendő: + Bejegyzések letöltése tőle: + Közzététel ebben a szobában + Szoba megnyitása + Átjátszó használata + Kapcsolódás a saját átjátszójához + Engedélyezés egyszer + Mindig engedélyezze ezt az átjátszót + Mindig kézbesítse az üzeneteimet + Átjátszó letiltása Átjátszónkénti felülírások - Nincs átjátszónkénti felülírás - a globális szabály mindenhol érvényes + Elfelejtés + Utoljára használva: %1$s + Itt még nincs semmi. A globális szabályzat minden átjátszóra vonatkozik. Engedélyezés Megtagadás Forrás: %1$s @@ -1738,6 +1775,110 @@ Nyilvános Csoport Új nyilvános vagy privát csoport + Átjátszócsoportok + Beágyazott + Átjátszó szerint + Jelenítsen meg minden csoportot saját beszélgetésként, a csevegései közé keverve. + Átjátszók csoportjainak összecsukása egyetlen sorba, a legújabb üzenetnél elhelyezve. + NIP-29 csoport megjelenítése + Átjátszócsoportok + Csoportok + Üzenetek + Csoport létrehozása + Ez az átjátszó nem hirdeti a NIP-29 átjátszócsoportok támogatását. Az itt létrehozott csoport nem fog működni. A nevét, a tagjait és az üzeneteit nem az átjátszó fogja kezelni. Válasszon egy olyan átjátszót, amely támogatja az átjátszóalapú csoportokat. + Csoport neve + Téma (nem kötelező) + Privát (csak a tagok olvashatják) + Csak meghívóval + Létrehozás + Emberek meghívása + Ossza meg ezt a kódot, hogy mások is csatlakozhassanak ehhez a csoporthoz. + Meghívó létrehozása… + Meghívókód másolva + Meghívókód (ehhez a csak meghívóval elérhető csoporthoz): + Csatlakozás a csoporthoz + Meghívókód + Ez a csoport csak meghívóval érhető el. Adja meg a kapott kódot. + Csatlakozás + Kérve + Adminisztrátor + Moderátor + Tag + Tagok + Kinevezés adminisztrátorrá + Kinevezés moderátorrá + Szerepkör eltávolítása + Eltávolítás a csoportból + Eltávolítja őt: %1$s, ebből a csoportból? Elveszíti a hozzáférést, amíg újra hozzá nem adják vagy meg nem hívják. + Csoport szerkesztése + Csoport neve + Téma (nem kötelező) + Privát (csak a tagok olvashatják) + Csak meghívóval + Mentés + Tagok + Csoport szerkesztése + Szálak + Csoport megnyitása + Még nincsenek csoportok ezen az átjátszón. + Meghívó előkészítése… + Privát + Csak meghívóval + Adjon meg egy érvényes átjátszó-webcímet (wss://…). + Név + Leírás + Csoport képe + Fénykép hozzáadása + Fénykép módosítása + Felfedezés + Segítsen másoknak is megtalálni ezt a csoportot. A témák és a helyszín megjelenítik a megfelelő felfedezési szűrők alatt (ha a kiszolgáló átjátszó támogatja azokat). + Témák + bitcoin, nostr, művészet + Helyszín (geo-kivonat) + u0nd + Engedélyek + Privát + Csak a tagok olvashatják az üzeneteket. + Csak meghívóval + Az embereket meg kell hívni vagy jóvá kell hagyni a csatlakozáshoz. + Csak tagok posztolhatnak + Csak a tagok tehetnek közzé üzeneteket. + Nem listázott + Csoport elrejtése az átjátszó nyilvános könyvtárából. + Csoportok keresése az átjátszók között… + Még nem találhatók csoportok ehhez a szűrőhöz. + Átjátszó hozzáadása a kedvencekhez + Még nincsenek szálak. Indítson egyet a + gombbal. + Új szál + Névtelen + Cím + Írjon valamit… + Közzététel + Csoportok keresése + Böngésszen az egy átjátszón tárolt csoportok között. Illesszen be egy átjátszócímet, vagy válasszon egyet alább. + Átjátszó címe + Böngészés + Átjátszók, amelyeken jelen van + Népszerű átjátszók + Még nincsenek üzenetek + Csatlakozzon ehhez a csoporthoz az üzenetek küldéséhez. + Ez a csoport csak meghívóval érhető el. Meghívóra van szüksége a közzétételhez. + + %1$d tag + %1$d tag + + + %1$d üzenet + %1$d üzenet + + + %1$d+ üzenet + %1$d+ üzenet + + + %1$d követett személy + %1$d Ön által követett személy + Privát Neki: Tárgy @@ -3299,6 +3440,84 @@ Aláírás Az üzenet végéhez lesz hozzáadva új bejegyzés, válasz, idézet vagy cikk írásakor. A letiltáshoz hagyja üresen. Saját alírás + Munkabizonyítás + Nehézségi szint + A közzététel előtt egy NIP-13 „munkabizonyítást” bányász a bejegyzéseibe, így az átjátszók és az olvasók mérlegelhetik azokat a kéretlen tartalommal szemben. A magasabb értékek bányászata exponenciálisan több időt vesz igénybe. A bejegyzések a bányászat után a háttérben lesznek közzétéve. + Kikapcsolva + Egyéni nehézségi szint + A cél finomhangolása vezető nulla bitekben. + Mit bányásszon + Az időkritikus események (átjátszóhitelesítés, zapkérések, pénztárca és aláíróüzenetek, piszkozatok, listák) soha nincsenek bányászva. + Rövid jegyzetek és válaszok + Az elsődleges nyilvános kéretlen tartalmi felület + Megjegyzések + Válaszok cikkekre, fájlokra és egyéb tartalmakra + Jelentések + Az átjátszók a költségük alapján mérlegelik a jelentéseket + Hosszú formátum és kiemelések + Cikkek és kiemelések; ritka, a költség elhanyagolható + Hangüzenetek + Nyilvános, hangalapú bejegyzések és válaszok + Újraközzétevések + Nagyszámú aktív felhasználónál + Reakciók + A legnagyobb mennyiségű típus; minden kedvelés bányászata meríti az akkumulátort + Nyilvános és élő csevegés + A bányászati késedelmek rontják a beszélgetés menetét + Privát üzenet burkolók + Lehetővé teszi a kéretlen tartalmak szűrését a beérkező közvetlen üzenetekben az átjátszók számára; csak a külső réteget bányássza, az üzenetet soha + Egyéb nyilvános tartalom + Szavazások, élő állapotok, apróhirdetések és minden egyéb nyilvános dolog + „Munkabizonyítás” bányászata + + „Munkabizonyítás” bányászata… (%1$d bejegyzés a sorban) + Proof of work bányászata… (%1$d bejegyzés a sorban) + + + %1$s • bányászat %2$d biten + %1$s • bányászat %2$d biten + + + %1$s • várakozás a bányászatra %2$d biten + %1$s • várakozás a bányászatra %2$d biten + + + Alapértelmezés (%1$d bit) + Alapértelmezés (%1$d bit) + + Alapértelmezés (kikapcsolva) + Kikapcsolva ehhez a bejegyzéshez + + %1$d bit + %1$d bit + + „Munkabizonyítás” bányászata + Azokat a bejegyzéseket jeleníti meg, amelyek még mindig a NIP-13 szerinti munkabizonyítást végzik, így azok befejeződhetnek, miután kilép az alkalmazásból. + Mégse + ≈ %1$s bejegyzésenként ezen az eszközön + <1 mp + + %1$d mp + %1$d mp + + + %1$d perc + %1$d perc + + + %1$d óra + %1$d óra + + + %1$d nap + %1$d nap + + ≈ %1$s van hátra + mostmár bármelyik pillanatban + A(z) %1$s bányászata befejeződött, de nem sikerült közzétenni: %2$s + A(z) %1$s bányászata befejeződött, de nem sikerült közzétenni. A következő indításkor az alkalmazás újra megpróbálja. + Csevegési üzenet + Jelentés Ennek használata Eltüntetés Javítás diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 2dc6a81508..ebdf6b3917 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -799,13 +799,50 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Uwierzytelnianie transmitera auth uwierzytelnianie transmiter podpis weryfikacja nip-42 tożsamość Polityka globalna + Jak się zalogować Zawsze uwierzytelniaj Poświadcz autoryzację dla każdego transmitera, który o to poprosi Nigdy nie uwierzytelniaj Ignoruj żądania uwierzytelnienia ze wszystkich transmiterów + Niestandardowa + Wybierz dokładnie, do których transmiterów chcesz się zalogować. Zostaniesz zapytany o wszystko, na co nie wyraziłeś zgody poniżej. + Moje transmitery i obiekty + Zaloguj się do własnych transmiterów oraz do publicznych czatów, społeczności i transmisji na żywo, do których dołączyłeś lub które dodałeś do ulubionych. + Czytaj posty od osób, które obserwuję + Zaloguj się na transmitery obserwowanych, aby pobrać ich posty. + Wyślij wiadomość do obserwowanych osób + Zaloguj się na transmitery obserwowanych, aby wysyłać prywatne wiadomości, odpowiedzi i powiadomienia. + Wiadomość do wszystkich + Zaloguj się do transmiterów nieznanych użytkowników, aby wysyłać prywatne wiadomości, odpowiedzi i powiadomienia. Opcja domyślnie wyłączona; za każdym razem pojawi się pytanie o zgodę. + Czy chcesz potwierdzić, że to właśnie ty łączysz się z tym transmiterem? + Ten transmiter chce najpierw potwierdzić, że to naprawdę ty. Jego operator sprawdzi, z jakiego konta się logujesz. + Wysłać wiadomość do %1$s? + Powiadom %1$s? + Wczytaj posty z %1$s? + Opublikować do %1$s? + Otworzyć %1$s? + Jeśli nie, Twoja wiadomość do %1$s nie zostanie dostarczona. + Jeśli nie, %1$s nie zostanie o tym powiadomiony. + Jeśli nie, nie zobaczysz tutaj postów z %1$s. + Jeśli nie, Twoja wiadomość do %1$s nie zostanie wysłana. + Jeśli nie, nie zobaczysz tutaj %1$s. + %1$s i inni + Wyślij prywatną wiadomość do: + Powiadom: + Pobierz posty z: + Opublikuj w tym pokoju + Otwórz ten pokój + Użyj tego transmitera + Podłącz się do własnego transmitera + Zezwól jednorazowo + Zawsze zezwalaj na dostęp do tego transmitera + Zawsze przekazuj moje wiadomości + Zablokuj ten transmiter Nadrzędność dla poszczególnych transmiterów + Zapomnij + Ostatnio użyty %1$s temu Brak nadrzędnych reguł dla transmiterów — polityka globalna obowiązuje wszędzie Zezwól Odmów @@ -1772,6 +1809,85 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Publiczna Grupa Nowa Grupa Publiczna lub Prywatna + Grupy Transmiterów + Inline + Wg transmitera + Wyświetlaj każdą grupę jako osobną rozmowę, umieszczoną wśród Twoich czatów. + Zwiń grupy poszczególnych transmiterów do jednego wiersza, umieszczając je przy najnowszej wiadomości. + Wyświetlanie grupy NIP-29 + Grupy Transmiterów + Grupy + Wiadomości + Utwórz grupę + Ten transmiter nie obsługuje grup zgodnych ze standardem NIP-29. Grupa utworzona tutaj nie będzie działać — transmiter nie będzie zarządzał jej nazwą, członkami ani wiadomościami. Wybierz transmiter, który obsługuje grupy oparte na rekomendacjach. + Nazwa grupy + Temat (opcjonalnie) + Prywatna (odczyt tylko dla członków) + Tylko zaproszenie + Utwórz + Zaproś osoby + Udostępnij ten kod, aby użytkownicy mogli dołączyć do tej grupy. + Tworzenie zaproszenia… + Kod zaproszenia skopiowany + Kod zaproszenia (dla tej grupy tylko zaproszenia): + Dołącz do grupy + Kod zaproszenia + Ta grupa jest dostępna wyłącznie z zaproszeniem. Wpisz otrzymany kod. + Dołącz + Wysłano prośbę + Admin + Moderator + Członek + Członkowie + Stwórz administratora + Stwórz moderatora + Usuń rolę + Usuń z grupy + Usunąć %1$s z tej grupy? Stracą dostęp do czasu ponownego dodania lub ponownego zaproszenia. + Edytuj grupę + Nazwa grupy + Temat (opcjonalnie) + Prywatna (odczyt tylko dla członków) + Tylko na zaproszenie + Zapisz + Członkowie + Edytuj grupę + Wątki + Grupa otwarta + Brak grup na tym przekaźniku transmiterze. + Przygotowywanie zaproszenia… + Prywatna + Tylko na zaproszenie + Wprowadź poprawny adres URL transmitera (wss://…). + Nazwa + Opis + Zdjęcie grupy + Dodaj zdjęcie + Zmień zdjęcie + Odkryj + Pomóż innym znaleźć tę grupę. Tematy i lokalizacja sprawiają, że grupa pojawia się w wynikach wyszukiwania przy użyciu odpowiednich filtrów (o ile transmiter hosta je obsługuje). + Tematy + bitcoin, nostr, sztuka + Lokalizacja (geohash) + u0nd + Uprawnienia + Prywatna + Tylko członkowie mogą czytać wiadomości. + Tylko na zaproszenie + Aby dołączyć, trzeba otrzymać zaproszenie lub zostać zatwierdzonym. + Publikowanie dostępne tylko dla członków + Tylko członkowie mogą wysyłać wiadomości. + Niewymieniona + Ukryj tę grupę w publicznym katalogu transmitera. + Szukam grup w Twoich transmiterach… + Nie znaleziono jeszcze żadnych grup dla tego filtra. + Faworyzuj ten transmiter + Brak wątków. Zacznij od przycisku +. + Nowy wątek + Bez tytułu + Tytuł + Napisz coś… + Post Prywatna Do Temat diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index e1caeba316..3ff2b4c951 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -774,19 +774,50 @@ 中继身份认证 auth authentication relay sign verify nip-42 identity 全局政策 + 登录什么 总是认证身份 为每个请求身份认证挑战的中继签署它 从不认证 忽略所有中继的认证挑战 + 自定义 选择登录到哪些中继。会询问你任何下面尚未允许的东西。 + 我的中继和场地 登录到您自己的中继和您加入或收藏的公共聊天室、社区和直播。 - 登录到以下中继之一来发送私信、回复和通知。 + 阅读我关注的人的帖子 + 登录到所关注者的中继下载其帖子。 + 给我关注的人留言 + 登录到所关注者的中继来发送私信、回复和通知。 + 给任何人留言 登录陌生人的中继来发送私信、回复和通知。默认情况下关闭;每次都要问您。 + 向该中继确认你的身份? 这个中继想要首先确认真的是你。它的运营者会看到你是哪个帐户。 + 向 %1$s 发送您的消息吗? + 通知 %1$s? + 从 %1$s 加载帖子? + 发帖到 %1$s? + 打开 %1$s? 如果不这么做,您给 %1$s 的消息将不会发送。 + 如果不这样做, %1$s 不会收到关于此内容的通知。 + 如果不这么做,你不会在这里看到 %1$s 的帖子。 + 如果不这么做,您给 %1$s 的消息将不会被发布。 + 如果不这么做,你不会在这里看到 %1$s。 + %1$s 和其他 + 发送您的私信到: + 通知: + 从何处下载帖子: + 发布到此房间 + 打开这个房间 + 使用此中继 + 连接到您自己的中继 + 允许一次 + 总是允许此中继 + 始终发送我的消息 + 屏蔽此中继 不同中继设置覆盖 + 忘记 + 上次使用于 %1$s 前 没有不同中继设置覆盖 — 所有中继使用全局政策 允许 拒绝 @@ -1726,14 +1757,106 @@ 公开 组 新建公开或私人群组 + 中继组 + 行内 + 根据中继 将每个群显示为单独对话,与您的聊天混合。 将每个中继的群组折叠为单行,放在最新消息上。 + NIP-29 群组显示 + 中继组 + 群组 + 消息 + 建群 此继电器不支持NIP-29中继群。 在这里创建的群将无法工作 — — 其名称、成员和消息将不会由中继管理。选择支持基于中继的群的中继。 + 群名称 + 主题(可选) + 私有(仅成员可读取) + 仅限邀请 + 创建 + 邀请其他人 + 分享这个代码,以便人们可以加入这个群。 + 正在创建邀请… + 已复制邀请码 + 邀请码 (用于此受邀加入群): + 加群 + 邀请码 此群只有受邀才能加入。请输入邀请码。 + 加入 + 已请求 + 管理员 + 协管 + 成员 + 成员 + 设为管理员 + 设为协管 + 删除职位 + 从群组中删除 从此群中移除 %1$s ?在重新添加或重新邀请之前这些人将失去访问权限。 + 编辑群 + 群名称 + 主题(可选) + 私有(仅成员可读取) + 仅限受邀 + 保存 + 成员 + 编辑群 + 主题帖 + 打开群组 + 此中继上尚没有群。 + 正在准备邀请… + 私密 + 仅限受邀 + 输入一个有效的中继 URL (wss://…)。 + 名称 + 描述 + 群组图片 + 添加照片 + 更改照片 + 发现 帮助人们找到这个群。主题和位置会把群置于匹配的发现筛选器下面 (如果托管中继支持)。 + 主题 + 比特币,nostr,艺术品 + 位置 (gehash) + u0nd + 权限 + 私密 + 只有成员可以阅读消息。 + 仅限受邀 + 人们必须被邀请或获批才能加入。 + 仅限成员发布 + 只有成员可以发布消息。 + 未列出的 + 在中继的公共目录中隐藏此群。 + 在您的中继中寻找群组… + 此筛选器尚未找到群组。 + 将该中继加入收藏 + 还没有主题帖。用+按钮开一个。 + 新主题帖 + 无标题 + 标题 + 写点什么… + 发表 + 查找群组 浏览中继托管的群组。粘贴一个中继地址,或在下方选择一个。 + 中继地址 + 浏览 + 您正身处的中继 + 热门中继 + 尚无消息 + 加入此群组发送消息。 此群只有受邀才能加入 —— 需要邀请才能发帖。 + + %1$d 位成员 + + + %1$d 条消息 + + + 超过 %1$d 条消息 + + + 您关注的 %1$d 人 + 私人 至 主题 @@ -2849,7 +2972,7 @@ 关注包 转发(16) Geohash 关注 - 包装礼物 + 礼物包装 Git 问题 Git 补丁 Git 仓库 @@ -3271,6 +3394,75 @@ 签名 在开启新帖子、回复、引用或文章时在消息末尾添加。留空则禁用。 您的签名 + 工作证明 + 难度 + 发布前将 NIP-13 工作证明开采进你的帖子以便中继和读者可以将帖子和垃圾信息进行权重比较。挖掘较大的值所需的时间会指数级增长。挖好后会在后台发布帖子。 + 关 + 自定义难度 + 微调前导零比特的目标。 + 要挖什么 + 时间紧迫的事件(继中继认证、 zap请求、 钱包和签名者消息、 草稿、 列表)从未被挖掘。 + 短笔记 & 回复 + 主要的公共垃圾邮件表面 + 评论 + 文章回复、文件和其他内容 + 举报 + 中继根据开销对举报进行加权 + 长文 & 重点 + 文章和重点;不经常发生;费用微不足道 + 语音消息 + 公共语音帖子和回复 + 转发 + 活跃用户的高容量 + 回应 + 量最大的类型;挖掘每个赞都会消耗电量 + 公开 & 实时聊天 + 采矿延迟伤害对话流 + 私密消息包装器 + 让私信中继过滤收件箱垃圾邮件;只挖掘外部包装,永远不会挖掘你的消息 + 其他公开内容 + 投票、实时状态、分类和所有其他所有公开信息 + 挖掘工作证明 + + 挖掘工作证明… (队列中有 %1$d 篇帖子) + + + %1$s • 在 %2$d 比特挖掘中 + + + %1$s • 正在等待在 %2$d 位上开采 + + + 默认 (%1$d 比特) + + 默认 (关闭) + 此帖关闭 + + %1$d 比特 + + 工作证明开采 + 显示仍在挖掘 NIP-13 工作证明的帖子,以便在你离开应用后它们可以完成挖掘。 + 取消 + 此设备上每个帖子约为 %1$s + <1 秒 + + %1$d 秒 + + + %1$d 分钟 + + + %1$d 小时 + + + %1$d 天 + + 剩余约 %1$s + 很快 + 您的 %1$s 完成了挖掘,但无法发布: %2$s + 您的 %1$s 完成了挖掘,但无法发布。将在下次应用程序启动时重新尝试。 + 聊天消息 + 举报 使用它 忽略 更正 diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 6f4cec4e36..0f6d7d1c6e 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -128,6 +128,18 @@ "Polish" ] }, + { + "user": "summoner001", + "languages": [ + "Hungarian" + ] + }, + { + "user": "hypnotichemionus4", + "languages": [ + "Chinese Simplified" + ] + }, { "user": "rajs19420616", "languages": [ @@ -140,12 +152,6 @@ "Slovenian" ] }, - { - "user": "hypnotichemionus4", - "languages": [ - "Chinese Simplified" - ] - }, { "user": "crowdin.pretended462", "languages": [ @@ -158,12 +164,6 @@ "French" ] }, - { - "user": "summoner001", - "languages": [ - "Hungarian" - ] - }, { "user": "Bardesss", "languages": [ From bc365c8ca2922ee3b745f38363fcbeb4d5d5097a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:00:42 +0000 Subject: [PATCH 059/206] 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 5683c74629d05aba08a003d64804a1390af9d294 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:13:32 +0000 Subject: [PATCH 060/206] perf(amethyst): mine on half the device's cores in the Android app Each PoW job now races PoWPolicy.minerWorkers(cores) = cores/2 parallel searches (at least 1) instead of a single thread, so a post on an 8-core phone mines ~4x faster while the other half of the cores stays free for the UI, and the total CPU budget matches the old 2-job x 1-thread pool. - PoWPolicy.minerWorkers(availableProcessors) is the single definition of the per-job worker budget. - PoWPublishQueue gains minerThreads (used by its built-in miner and exposed for custom mine lambdas); AppModules wires maxConcurrent = 1 + minerThreads = half the cores. - PoWNostrSigner gains a workers param (default 1), covering reactions, reposts and reports via Account.miningSigner; the anonymous-post paths in the short-note and comment composers pass the same budget. - Gift-wrap envelope mining stays single-threaded: the template conversion is a non-suspend hook inside the synchronous NIP-59 build. - UI time estimates (settings picker, composer chip, broadcast banner) now benchmark at the queue's worker count via deviceHashesPerSecond(), and PoWEstimator caches one rate per worker count. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UhaF5scnAvhP9wr9R7bTWM --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 ++++-- .../vitorpamplona/amethyst/model/Account.kt | 17 ++++++-- .../amethyst/service/pow/PowDuration.kt | 11 +++++ .../amethyst/ui/broadcast/BroadcastBanner.kt | 3 +- .../ui/note/creators/pow/PowOverrideButton.kt | 3 +- .../nip22Comments/CommentPostViewModel.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 2 +- .../settings/ComposeSettingsScreen.kt | 3 +- .../commons/service/pow/PoWEstimator.kt | 43 +++++++++++-------- .../amethyst/commons/service/pow/PoWPolicy.kt | 7 +++ .../commons/service/pow/PoWPublishQueue.kt | 8 +++- .../commons/service/pow/PoWPolicyTest.kt | 10 +++++ .../quartz/nip13Pow/signer/PoWNostrSigner.kt | 7 ++- 13 files changed, 95 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index e7567bb777..34ade716a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver +import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account @@ -618,9 +619,12 @@ class AppModules( ) // fire-and-forget NIP-13 mining: posts queue here and publish when mined. - // Capped worker pool so a burst of sends never spawns unbounded miners. - // Template jobs checkpoint to disk (restored on login) and every enqueue - // raises the shortService shield so backgrounding doesn't freeze a miner. + // One job mines at a time, racing half the cores over disjoint nonce + // slices — same total CPU budget as the old 2-job pool, but each post + // finishes ~minerThreads× sooner and the other half of the cores stays + // free for the UI. Template jobs checkpoint to disk (restored on login) + // and every enqueue raises the shortService shield so backgrounding + // doesn't freeze a miner. val powJobStore by lazy { PowJobStore(File(appContext.filesDir, PowJobStore.FILE_NAME), applicationIOScope) } @@ -628,7 +632,8 @@ class AppModules( val powPublishQueue by lazy { PoWPublishQueue( scope = applicationIOScope, - maxConcurrent = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 2), + maxConcurrent = 1, + minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()), persistence = powJobStore, onQueueActive = { PowMiningForegroundService.start(appContext) }, ) 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 3ec8a23598..798aef0e4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -824,6 +824,14 @@ class Account( else -> overrideDifficulty } + /** + * Parallel workers a single nonce search should use — the mining queue's + * per-job budget (half the device's cores). 1 when no queue is wired. + * Callers that run [PoWMiner] inside a queued job must pass this so the + * job stays inside the queue's CPU budget. + */ + fun powMinerWorkers(): Int = powQueue()?.minerThreads ?: 1 + /** * Enqueues [work] into the fire-and-forget mining queue. Returns false when * no queue is wired (headless/test accounts): callers must then run their @@ -932,7 +940,9 @@ class Account( mine = { isActive -> // the wrap's ephemeral key is generated inside the wrap build; // the conversion hook hands its pubkey back so the nonce can - // commit to it. + // commit to it. Single-threaded on purpose: the conversion is + // a non-suspend hook deep inside the synchronous NIP-59 wrap + // build, so it can't race PoWMiner.mine workers. val mineWrap: GiftWrapTemplateConversion = { template, ephemeralPubKey -> PoWMiner.run(template, ephemeralPubKey, difficulty, isActive) } @@ -964,14 +974,15 @@ class Account( isActive: () -> Boolean, ): NostrSigner { val currentSigner = signer + val workers = powMinerWorkers() return if (currentSigner is NostrSignerWithClientTag) { NostrSignerWithClientTag( - inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive), + inner = PoWNostrSigner(currentSigner.inner, difficulty, kindsToMine, isActive, workers), clientTag = currentSigner.clientTag, disabled = currentSigner.disabled, ) } else { - PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive) + PoWNostrSigner(currentSigner, difficulty, kindsToMine, isActive, workers) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt index fa4eef5166..da95a540b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowDuration.kt @@ -22,10 +22,21 @@ package com.vitorpamplona.amethyst.service.pow import android.content.Context import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes import kotlin.math.roundToLong +/** + * This device's effective mining rate: the [PoWEstimator] benchmark run with + * the same worker count the mining queue uses (half the cores, see + * [PoWPolicy.minerWorkers] and AppModules.powPublishQueue). Every UI estimate + * must use this rate, or it would describe a single-threaded miner that no + * longer exists. Cached after the first call (~250 ms). + */ +suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors())) + /** * "45 seconds" / "10 minutes" / "3 hours" — the one human-readable rendering * of a PoW duration estimate, shared by the settings picker, the composer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt index 17ef204ce8..c241f50787 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/broadcast/BroadcastBanner.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastStatus import com.vitorpamplona.amethyst.commons.service.broadcast.RelayResult import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState +import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond import com.vitorpamplona.amethyst.service.pow.formatTimeLeft import com.vitorpamplona.amethyst.service.pow.powKindLabelRes import com.vitorpamplona.amethyst.ui.stringRes @@ -197,7 +198,7 @@ private fun MiningContent( val context = LocalContext.current val hashRate by produceState(initialValue = null) { - value = PoWEstimator.hashesPerSecond() + value = deviceHashesPerSecond() } Column(modifier = Modifier.fillMaxWidth()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt index 22e0dda3a5..ba040646b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/pow/PowOverrideButton.kt @@ -48,6 +48,7 @@ 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.service.pow.PoWEstimator +import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond import com.vitorpamplona.amethyst.service.pow.formatApproxDuration import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow @@ -117,7 +118,7 @@ fun PowOverrideButton( val context = LocalContext.current val hashRate by produceState(initialValue = null) { - value = PoWEstimator.hashesPerSecond() + value = deviceHashesPerSecond() } fun eta(difficulty: Int): String? = hashRate?.let { formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, it)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 740a62cea5..3c6b6d9b79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -575,7 +575,7 @@ open class CommentPostViewModel : // fresh created_at at mining start (NIP-13 recommendation): // the job may have waited in the queue behind other posts. val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) - val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) + val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 39434ccf93..3cea485aef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1056,7 +1056,7 @@ open class ShortNotePostViewModel : // fresh created_at at mining start (NIP-13 recommendation): // the job may have waited in the queue behind other posts. val fresh = EventTemplate(TimeUtils.now(), template.kind, template.tags, template.content) - val mined = PoWMiner.run(fresh, anonSigner.pubKey, powDifficulty, isActive) + val mined = PoWMiner.mine(fresh, anonSigner.pubKey, powDifficulty, accountViewModel.account.powMinerWorkers(), isActive) accountViewModel.account.signAnonymouslyAndBroadcast(mined, extraNotesToBroadcast, anonSigner) accountViewModel.account.deleteDraftIgnoreErrors(draftToDelete) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt index e28eeb82bf..c70b623f31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ComposeSettingsScreen.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.model.AccountPoWPreferences import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.service.pow.deviceHashesPerSecond import com.vitorpamplona.amethyst.service.pow.formatApproxDuration import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -195,7 +196,7 @@ private fun PowTimeEstimate(difficulty: Int) { val context = LocalContext.current val estimate by produceState(initialValue = null, difficulty) { - val rate = PoWEstimator.hashesPerSecond() + val rate = deviceHashesPerSecond() value = formatApproxDuration(context, PoWEstimator.estimateSeconds(difficulty, rate)) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt index 1ed00fc1ff..5caef9b9d2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWEstimator.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -50,37 +51,41 @@ object PoWEstimator { private const val BATCH = 2_000 private val BENCH_DURATION = 250.milliseconds - private var cachedRate: Double? = null + // cached per worker count; guarded by benchLock (single-flight so + // concurrent first callers — e.g. the settings screen recomposing while + // the composer chip opens — share one ~250 ms benchmark instead of each + // burning the cores). + private val cachedRates = mutableMapOf() private val benchLock = Mutex() - suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double = - cachedRate ?: withContext(dispatcher) { - // single-flight: concurrent first callers (e.g. the settings screen - // recomposing while the composer chip opens) share one ~250 ms - // benchmark instead of each burning a core. - benchLock.withLock { - cachedRate ?: benchmark().also { cachedRate = it } - } - } + suspend fun hashesPerSecond(dispatcher: CoroutineDispatcher = Dispatchers.Default): Double = hashesPerSecond(1, dispatcher) /** * Aggregate hash rate with [workers] concurrent miners — what * [com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner.mine] achieves when - * racing that many workers. Measured live (not cached): each worker runs - * its own ~250 ms benchmark loop concurrently and the rates are summed, - * so contention between cores is priced in. + * racing that many workers. Each worker runs its own ~250 ms benchmark + * loop concurrently and the rates are summed, so contention between + * cores is priced in. */ suspend fun hashesPerSecond( workers: Int, dispatcher: CoroutineDispatcher = Dispatchers.Default, - ): Double = - if (workers <= 1) { - hashesPerSecond(dispatcher) - } else { - withContext(dispatcher) { - List(workers) { async { benchmark() } }.awaitAll().sum() + ): Double { + val count = workers.coerceAtLeast(1) + benchLock.withLock { + cachedRates[count]?.let { return it } + return withContext(dispatcher) { + val rate = + if (count == 1) { + benchmark() + } else { + coroutineScope { List(count) { async { benchmark() } }.awaitAll().sum() } + } + cachedRates[count] = rate + rate } } + } fun estimateSeconds( difficulty: Int, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt index 158ccb7adb..d4c0da8079 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicy.kt @@ -86,6 +86,13 @@ object PoWPolicy { */ const val MAX_DIFFICULTY = 40 + /** + * How many parallel miner workers one mining job should use on a device + * with [availableProcessors] cores: half of them, so a running miner never + * starves the UI/render threads or the relay client. At least 1. + */ + fun minerWorkers(availableProcessors: Int): Int = (availableProcessors / 2).coerceAtLeast(1) + private const val LONG_FORM_DRAFT_KIND = 30024 /** NIP-51 sets and other settings-like addressable kinds. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt index 4f69808bea..d590b9e344 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPublishQueue.kt @@ -96,7 +96,10 @@ data class PoWJobFailure( * to the normal sign+broadcast continuation captured at enqueue time. * * FIFO with at most [maxConcurrent] concurrent miners so a burst of posts - * queues up instead of spawning unbounded CPU work. Jobs are cancellable + * queues up instead of spawning unbounded CPU work. Each job's nonce search + * runs on [minerThreads] parallel workers (see PoWMiner.mine) — callers that + * bring their own mine lambda via [enqueueStaged]/[enqueueWork] should reuse + * this value to stay inside the queue's CPU budget. Jobs are cancellable * while QUEUED or MINING; once PUBLISHING starts, cancel is a no-op. * * Template jobs enqueued with a [PersistedPoWJob] record are checkpointed to @@ -115,6 +118,7 @@ data class PoWJobFailure( class PoWPublishQueue( private val scope: CoroutineScope, maxConcurrent: Int = 1, + val minerThreads: Int = 1, miningDispatcher: CoroutineDispatcher = Dispatchers.Default, private val persistence: PoWJobPersistence? = null, private val onQueueActive: () -> Unit = {}, @@ -198,7 +202,7 @@ class PoWPublishQueue( } else { template } - PoWMiner.run(toMine, pubKey, difficulty, isActive) + PoWMiner.mine(toMine, pubKey, difficulty, minerThreads, isActive) }, publish = onMined, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt index d1822331fa..db53f1570d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWPolicyTest.kt @@ -28,6 +28,16 @@ import kotlin.test.assertTrue class PoWPolicyTest { val allCategories = PoWCategory.entries.toSet() + @Test + fun minerWorkersIsHalfTheCoresButNeverZero() { + assertEquals(1, PoWPolicy.minerWorkers(1)) + assertEquals(1, PoWPolicy.minerWorkers(2)) + assertEquals(2, PoWPolicy.minerWorkers(4)) + assertEquals(3, PoWPolicy.minerWorkers(6)) + assertEquals(4, PoWPolicy.minerWorkers(8)) + assertEquals(1, PoWPolicy.minerWorkers(0)) + } + @Test fun difficultyOffMinesNothing() { assertNull(PoWPolicy.shouldMine(1, 0, allCategories)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt index b114259885..0aeb529e1e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip13Pow/signer/PoWNostrSigner.kt @@ -38,12 +38,16 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent * Only events whose kind is in [kindsToMine] are mined; anything else (e.g. the * seal and rumor of a gift-wrapped flow) passes through untouched. Templates that * already carry a nonce tag are not mined again. + * + * [workers] parallel searches race over disjoint nonce slices (see + * [PoWMiner.mine]); 1 keeps the historical single-threaded behavior. */ class PoWNostrSigner( val signer: NostrSigner, val desiredPoW: Int, val kindsToMine: Set, val isActive: () -> Boolean = { true }, + val workers: Int = 1, ) : NostrSigner(signer.pubKey) { override fun isWriteable(): Boolean = signer.isWriteable() @@ -55,10 +59,11 @@ class PoWNostrSigner( ): T = if (kind in kindsToMine && tags.none { PoWTag.hasTagWithContent(it) }) { val mined = - PoWMiner.run( + PoWMiner.mine( template = EventTemplate(createdAt, kind, tags, content), pubKey = pubKey, desiredPoW = desiredPoW, + workers = workers, isActive = isActive, ) signer.sign(mined.createdAt, mined.kind, mined.tags, mined.content) From 5365311e7d504c4316f7be2c4b8439b0e6b74db0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:22:09 +0000 Subject: [PATCH 061/206] fix: describe relay auth read permission as profile, posts and engagement The READ_OUTBOX relay-auth prompt and the settings toggle described the permission as only loading posts, but authenticating to a follow's relay also downloads their profile information and post engagement. Update the prompt title, consequence line, reason label and toggle description to say so. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012VmzSAtkFv29KWDzPVBbxS --- amethyst/src/main/res/values/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 05f3fee146..994f57d2a0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -829,7 +829,7 @@ My relays and venues Log in to your own relays and to public chats, communities and live streams you\'ve joined or favorited. Read posts from people I follow - Log in to a follow\'s relays to download their posts. + Log in to a follow\'s relays to download their profile information, posts and post engagement. Message people I follow Log in to a follow\'s relays to send DMs, replies and notifications. Message anyone @@ -839,19 +839,19 @@ Send your message to %1$s? Notify %1$s? - Load posts from %1$s? + Load profile, posts and engagement from %1$s? Post to %1$s? Open %1$s? If you don\'t, your message to %1$s won\'t be delivered. If you don\'t, %1$s won\'t be notified about this. - If you don\'t, you won\'t see posts from %1$s here. + If you don\'t, you won\'t see the profile, posts or engagement from %1$s here. If you don\'t, your message to %1$s won\'t be posted. If you don\'t, you won\'t see %1$s here. %1$s and others Send your private message to: Notify: - Download posts from: + Download profile information, posts and engagement from: Post to this room Open this room Use this relay From d1e5a4ef83242eff1a89131a6929438984a9d2ef Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 11 Jul 2026 13:20:41 +0100 Subject: [PATCH 062/206] update cs,sv,de,pt --- amethyst/src/main/res/values-cs/strings.xml | 581 ++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 525 ++++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 553 +++++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 548 +++++++++++++++++ 4 files changed, 2207 insertions(+) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f826000683..38b02a8e34 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3198,4 +3198,585 @@ Chyba při analýze JSON z načtení faktury %1$s. Zkontrolujte uživatelské bleskové nastavení Nelze vytvořit bleskovou fakturu. Zpráva od %1$s: %2$s Nelze vytvořit bleskovou fakturu z %1$s: Prvek pr nebyl nalezen ve výsledném JSON. + + Barva zvýraznění + Modrá + Hlavní barva používaná napříč tlačítky a odkazy + Zelená + Oranžová + Růžová + Fialová + Červená + Kategorie + Implementuje + Aplikace + NIP-%1$s + Související + Podporované NIP + prostřednictvím %1$s + Detekce ptáků + Aplikace od lidí, které sledujete + Weby od lidí, které sledujete + Objevujte webové aplikace + Vyberte mincovny + Nyní vyberte několik mincoven, které budou hostit vaše sats. + Peněženka vytvořena + Vaše peněženka se ukládá automaticky, jak přidáváte nebo odebíráte mincovny. Klíč nutzap se pro vás vytvoří při prvním přidání mincovny. + Ukládání… + Nastavování vaší peněženky… + Ověřování peněženek, které jsme našli… + Vytvořit novou peněženku + Tuto peněženku nelze přečíst + Nejnovější — vaše hlavní peněženka + Mincovny: %1$s + Máte v síti více než jednu peněženku Cashu — nejspíše vytvořené různými aplikacemi. Nejnovější se stane vaší hlavní peněženkou; převeďte do ní veškeré prostředky ze starších. + Našli jsme několik peněženek + Žádné obnovitelné prostředky + Na žádném relay jsme nenašli existující peněženku Cashu pod vaším klíčem Nostr. Začněte vytvořením nové. + Žádná peněženka nenalezena + Starší peněženka + Obnovit prostředky do hlavní peněženky + %1$s sats k obnovení + Obnoveno %1$s sats + Hledat znovu + Hledání vaší peněženky… + Prohledáváme každý relay a hledáme peněženku Cashu publikovanou pod vaším klíčem Nostr. + Prohledávání relayů… %1$d / %2$d + Nastavit jako hlavní peněženku + Nejprve nastavte hlavní peněženku, poté obnovte prostředky ze starších. + Tato peněženka Cashu je publikována v síti Nostr pod vaším klíčem. Používejte ji na tomto zařízení — znovu ji rozešleme na vaše relaye, aby se příště snadno našla. + Našli jsme vaši peněženku + Nastavte si peněženku + Použít tuto peněženku + Přidá se na konec zprávy při otevírání nového příspěvku, odpovědi, citace nebo článku. Ponechte prázdné pro vypnutí. + Váš podpis + Podpis + AMRAP + EMOM + Písmo + Typ písma používaný v celé aplikaci + Neproporcionální + Bezpatkové + Patkové + Výchozí systémové + Velikost písma + Změna velikosti textu v celé aplikaci + Obrovská + Velká + Normální + Malá + Větev + Commit + Binární soubor se nezobrazuje. + Uzavřít problém + Znovu otevřít + Základ sloučení + Popis + Nový + Zrušit + Vytvořit + Štítky + bug, vylepšení… + Název + Nový problém + Zkusit načíst změny znovu + Načítání změn… + Nebyly nalezeny žádné změny souborů. + Upraveno + Pull request byl aktualizován na nový commit. + Zobrazit změny + Binární soubor (%1$s) — náhled není k dispozici. + Přidat repozitář do záložek + Větve + Repozitář se nepodařilo načíst z jeho klonovací URL. + Načítání repozitáře… + Commity + Kopírovat obsah souboru + výchozí + Tato složka je prázdná. + Tento repozitář nelze klonovat přes http(s), takže zde není zobrazení kódu k dispozici. + Hostováno externě + Tento soubor se nepodařilo načíst. + Uzavřené a vyřešené + Otevřené + Vše + Spravuje + Toto oznámení repozitáře neobsahuje žádnou http(s) klonovací URL k procházení. + Není k dispozici žádná historie commitů. + Žádné odpovídající soubory. + Otevřít v prohlížeči + Text + Tento repozitář nemá soubor README. + Nedávná aktivita + Zkusit znovu + kořen + Hledat soubory… + Klonovací URL (jedna na řádek) + Popis + Název + Uložit + Upravit repozitář + Témata (oddělená čárkami) + Webové URL (jedna na řádek) + Větve + Soubory + Značky + Aktualizováno + Kód + Readme + Značky + Git repozitáře + Uzavřít + Označit jako sloučené + Znovu otevřít + Bez názvu + Pull request + Aktualizace PR + Relaye + %1$s… + Zprávy + Relaye + Shell + nApplet: %1$s + Zablokovat a ignorovat %1$s + Připojit + Jak se mají zpracovávat požadavky této aplikace? + se chce připojit k vašemu účtu Nostr + Připojit k Nostr + Oprávnění + Zatím se nepřipojily žádné aplikace.\n\nAž se webová aplikace připojí k vašemu klíči Nostr, objeví se zde. + Zapomenout tuto aplikaci + Výjimky pro podepisovací operace + Úroveň důvěry pro podepisování + aplikace oprávnění podepisovač napplet nsite webová aplikace důvěra připojené + Méně možností + Skrýt událost + Více možností + Zobrazit událost + chce %1$s + Povolit + Zeptat se + Odmítnout + nApplet %1$s… + číst vaše soukromé zprávy + zašifrovat zprávu + podepsat událost druhu %1$d + podepsat pro %1$s (druh: %2$d) + Zeptat se pokaždé + Výjimky operací + Odvolat všechna oprávnění + Připojené aplikace + Plně jí důvěřuji + Automaticky podepisovat všechny požadavky (kromě plateb) + Jsem trochu paranoidní + Nepodepisuj nic, aniž by ses mě zeptal! + Buďme rozumní + Automaticky schvalovat nejběžnější požadavky + Povolit na 24 hodin + Povolit na 30 dní + Znovu se neptat na žádné požadavky Nostr + Povolit jednou + Znovu se neptat na %1$s + Povolit pro tuto relaci + Odmítnout + Vždy odmítnout %1$s + Vyprší + Naposledy použito + Zatím se nepřipojily žádné aplikace. + Odvolat všechna oprávnění + nApplety + nSites + Ověřený autor + Podcasty + Vaše podcasty a epizody v záložkách + od %1$s + Kapitoly + Dokončeno + Přidat obal + Čtvercový obrázek zobrazený u epizody + Nastavte název, obal a podrobnosti svého pořadu + Vytvořte svůj podcast + Upravit epizodu + Váš podcast + Zvukový soubor připraven k nahrání + Přidat zvukový soubor + MP3, M4A nebo jiný zvuk + URL zvuku + https://…/epizoda.mp3 + URL kapitol + Smazat epizodu + Smazat tuto epizodu? Tuto akci nelze vrátit zpět. + Délka (sekundy) + Více podrobností + Ep %1$d + Epizoda č. + Série + Shrnutí + Název + Název epizody + Témata + čárkami, oddělené, štítky + URL přepisu + URL videa + Explicitní + Moderátoři a hosté + Nová epizoda + Nová upoutávka + Zatím žádné epizody. Klepnutím na Nová epizoda publikujte svou první. + Přehrát ukázku + Prémiový + Publikování… + Spolumoderátor + Editor + Moderátor + Série %1$d + S%1$d · E%2$d + Autor + Kategorie + Technologie, Zprávy + Pořad kompletní (žádné další epizody) + Autorská práva + Přidat obal + Čtvercový obal vašeho pořadu + Popis + Kontaktní e-mail + Explicitní obsah + Odkazy na financování + https://… + Jazyk + cs + Uzamčeno (prémiové) + Název pořadu + Můj podcast + Epizodický + Typ pořadu + Seriálový + Web + Podpořte pořad + Klepnutím upravíte podrobnosti pořadu + Nejlepší podporovatelé + Upoutávka + Série %1$d + Název upoutávky + Přidat klip upoutávky + Krátký zvukový nebo video náhled + URL média + Přepis + Bez názvu + Přidat adresu ručně + Přidat příjemce + Přidejte příjemce pro rozdělení příchozích sats podle váhy. Posluchači posílají boost nebo streamují hodnotu do těchto cílů. + Chyba Value-for-Value + Poplatek + Value-for-Value + Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel). + Lightning adresa + jmeno@example.com + Tento podcast nemá žádné platitelné příjemce hodnoty. + Veřejný klíč uzlu + 02abc… (33bajtový hex) + Jméno (volitelné) + Odebrat příjemce + Přidat uživatele Nostr + Hledat podle jména nebo @handle + %1$d%% + Streamovat sats + Automaticky posílá hodnotu, když posloucháte. + %1$d sats/min + Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu. + V této relaci odesláno %1$d sats + Lightning adresa + Uzel (keysend) + Tento uživatel nemá Lightning adresu + Váha + Zapy na toto se rozdělují mezi: + Video + Váš podcast + Časově kritické události (autentizace relay, žádosti o zap, zprávy peněženky a podepisovače, koncepty, seznamy) se nikdy netěží. + Co těžit + Komentáře + Odpovědi na články, soubory a jiný obsah + Obálky soukromých zpráv + Umožňuje DM relayům filtrovat spam ve schránce; těží se pouze vnější obálka, nikdy vaše zpráva + Dlouhé texty a zvýraznění + Články a zvýraznění; zřídkavé, náklady jsou zanedbatelné + Jiný veřejný obsah + Ankety, živé statusy, inzeráty a vše ostatní veřejné + Veřejný a živý chat + Prodlevy při těžení narušují plynulost konverzace + Reakce + Nejobjemnější druh; těžení každého lajku stojí baterii + Nahlášení + Relaye váží nahlášení podle jejich nákladů + Přeposlání + Vysoký objem pro aktivní uživatele + Krátké poznámky a odpovědi + Hlavní veřejná plocha pro spam + Hlasové zprávy + Veřejné hlasové příspěvky a odpovědi + Jemné doladění cíle v počtu úvodních nulových bitů. + Vlastní obtížnost + ≈ %1$s na příspěvek na tomto zařízení + Vytěží do vašich příspěvků NIP-13 proof of work před publikováním, aby je relaye a čtenáři mohli zvážit oproti spamu. Vyšší hodnoty trvají exponenciálně déle. Příspěvky se po vytěžení publikují na pozadí. + Vypnuto + Obtížnost + <1 s + Chatová zpráva + Nahlášení + Těžení proof of work + Zrušit + Zobrazuje příspěvky, které stále těží svůj NIP-13 proof of work, aby mohly dokončit těžbu i po opuštění aplikace. + Těžení proof of work + Výchozí (vypnuto) + Vypnuto pro tento příspěvek + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat: %2$s + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat. Bude to zkuseno znovu při příštím spuštění aplikace. + Proof of Work + ≈ %1$s zbývá + každou chvíli + Bitcoin (on-chain) peněženka + blok %1$d + Prázdný slot + Uložení na paměťovou kartu PS1 + Povolit jednou + Vždy povolit tento relay + Vždy doručovat mé zprávy + Zablokovat tento relay + Pokud tak neučiníte, %1$s o tom nebude upozorněn. + Pokud tak neučiníte, vaše zpráva na %1$s nebude odeslána. + Pokud tak neučiníte, neuvidíte zde příspěvky od %1$s. + Pokud tak neučiníte, neuvidíte zde %1$s. + Pokud tak neučiníte, vaše zpráva pro %1$s nebude doručena. + Kam se přihlásit + Povolit + Odmítnout + Zapomenout + Kdy se ověřovat + Naposledy použito před %1$s + %1$s a další + Zatím zde nic není — na každý relay se vztahuje vaše globální zásada. + Výjimky pro jednotlivé relaye + Vždy se ověřovat + Podepisovat autentizační výzvy pro každý relay, který o to požádá + Vlastní + Vyberte přesně, ke kterým relayům se přihlásit. Na cokoli, co jste níže nepovolili, budete dotázáni. + Nikdy se neověřovat + Ignorovat autentizační výzvy ze všech relayů + Tento relay si nejprve chce ověřit, že jste to opravdu vy. Jeho provozovatel uvidí, který účet jste. + Potvrdit tomuto relay, že jste to vy? + Připojit se ke svému vlastnímu relay + Upozornit: + Použít tento relay + Přispět do této místnosti + Stáhnout příspěvky z: + Otevřít tuto místnost + Odeslat vaši soukromou zprávu pro: + auth autentizace relay podepsat ověřit nip-42 identita + Autentizace relay + Upozornit %1$s? + Přispět do %1$s? + Načíst příspěvky z %1$s? + Otevřít %1$s? + Odeslat vaši zprávu pro %1$s? + Zprávy lidem, které sleduji + Přihlásit se k relayům sledovaného pro odesílání DM, odpovědí a upozornění. + Zprávy komukoli + Přihlásit se k relayům cizích lidí pro odesílání DM, odpovědí a upozornění. Ve výchozím nastavení vypnuto; místo toho budete dotázáni pokaždé. + Mé relaye a místnosti + Přihlásit se ke svým vlastním relayům a k veřejným chatům, komunitám a živým vysíláním, ke kterým jste se připojili nebo je oblíbili. + Číst příspěvky od lidí, které sleduji + Přihlásit se k relayům sledovaného pro stažení jeho příspěvků. + Přidat fotku + Pouze na pozvání + Soukromá + Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže. + Procházet + Zadejte platnou URL relay (wss://…). + Oblíbené relaye + Adresa relay + Najít skupiny + Relaye, na kterých jste + Změnit fotku + Na tomto relay zatím nejsou žádné skupiny. + Vytvořit + Pouze na pozvání + Název skupiny + Soukromá (čtení jen pro členy) + Vytvořit skupinu + Téma (volitelné) + Odebrat roli + Hledání skupin napříč vašimi relayi… + Pro tento filtr zatím nebyly nalezeny žádné skupiny. + Uložit + Pouze na pozvání + Název skupiny + Soukromá (čtení jen pro členy) + Upravit skupinu + Téma (volitelné) + Oblíbit tento relay + Popis + Poloha (geohash) + u0nd + Název + Obrázek skupiny + Témata + bitcoin, nostr, umění + Neuvedená + Skrýt tuto skupinu z veřejného adresáře relay. + Pouze na pozvání + Lidé musí být pozváni nebo schváleni, aby se mohli připojit. + Soukromá + Zprávy mohou číst pouze členové. + Přispívání jen pro členy + Zprávy mohou psát pouze členové. + Kód pozvánky (pro tuto skupinu na pozvání): + Kód pozvánky zkopírován + Sdílejte tento kód, aby se lidé mohli připojit k této skupině. + Vytváření pozvánky… + Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku. + Příprava pozvánky… + Pozvat lidi + Tato skupina je pouze na pozvání. Zadejte kód, který jste dostali. + Kód pozvánky + Připojit se + Připojit se ke skupině + Připojte se k této skupině pro odesílání zpráv. + Udělat správcem + Udělat moderátorem + Členové + Upravit skupinu + Členové + Zatím žádné zprávy + Otevřít skupinu + Požádáno + Tento relay neuvádí podporu skupin NIP-29. Skupina zde vytvořená nebude fungovat — její název, členové a zprávy nebudou relayem spravovány. Vyberte relay, který podporuje skupiny založené na relay. + Odebrat ze skupiny + Odebrat %1$s z této skupiny? Ztratí přístup, dokud nebude znovu přidán nebo pozván. + Správce + Člen + Moderátor + Objevování + Pomozte lidem najít tuto skupinu. Témata a poloha ji zobrazí pod odpovídajícími filtry objevování (pokud je hostitelský relay podporuje). + Oprávnění + Skupiny relay + Napište něco… + Nové vlákno + Přispět + Název + Bez názvu + Zatím žádná vlákna. Založte jedno tlačítkem +. + Vlákna + Podle relay + Sbalí skupiny každého relay do jednoho řádku, umístěného u jeho nejnovější zprávy. + V řadě + Zobrazí každou skupinu jako vlastní konverzaci, promíchanou s vašimi chaty. + Zobrazení skupin NIP-29 + Skupiny + Skupiny relay + Repozitáře + Vaše git repozitáře v záložkách + %1$s km + + + %1$d soubor změněn + %1$d soubory změněny + %1$d souboru změněno + %1$d souborů změněno + + + %1$d položka + %1$d položky + %1$d položky + %1$d položek + + + %1$d kapitola + %1$d kapitoly + %1$d kapitoly + %1$d kapitol + + + %1$d upoutávka + %1$d upoutávky + %1$d upoutávky + %1$d upoutávek + + + %1$d den + %1$d dny + %1$d dne + %1$d dní + + + %1$d hodina + %1$d hodiny + %1$d hodiny + %1$d hodin + + + %1$d minuta + %1$d minuty + %1$d minuty + %1$d minut + + + %1$d sekunda + %1$d sekundy + %1$d sekundy + %1$d sekund + + + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + + + Těžení proof of work… (%1$d příspěvek ve frontě) + Těžení proof of work… (%1$d příspěvky ve frontě) + Těžení proof of work… (%1$d příspěvku ve frontě) + Těžení proof of work… (%1$d příspěvků ve frontě) + + + %1$d bit + %1$d bity + %1$d bitu + %1$d bitů + + + Výchozí (%1$d bit) + Výchozí (%1$d bity) + Výchozí (%1$d bitu) + Výchozí (%1$d bitů) + + + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + + + %1$d osoba, kterou sledujete + %1$d osoby, které sledujete + %1$d osoby, které sledujete + %1$d osob, které sledujete + + + %1$d člen + %1$d členové + %1$d člena + %1$d členů + + + %1$d zpráva + %1$d zprávy + %1$d zprávy + %1$d zpráv + + + %1$d+ zpráva + %1$d+ zprávy + %1$d+ zprávy + %1$d+ zpráv + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 1f23f55b23..8b9f878534 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3140,4 +3140,529 @@ Fehler beim Parsen von JSON von %1$s. Überprüfen Sie die Blitzeinrichtung des Benutzers Blitzrechnung konnte nicht erstellt werden. Nachricht von %1$s: %2$s Kann keine Blitzrechnung von %1$s erstellen: Element pr nicht im resultierenden JSON gefunden. + Akzentfarbe + Blau + Hauptfarbe für Schaltflächen und Links + Grün + Orange + Pink + Lila + Rot + Implementiert + App + NIP-%1$s + Verwandt + Unterstützte NIPs + über %1$s + Vogelerkennung + Apps von Personen, denen du folgst + Sites von Personen, denen du folgst + Web-Apps entdecken + Mints auswählen + Wähle nun ein paar Mints, die deine Sats verwahren. + Wallet erstellt + Deine Wallet wird automatisch gespeichert, während du Mints hinzufügst oder entfernst. Ein Nutzap-Schlüssel wird für dich erstellt, sobald du das erste Mint hinzufügst. + Wird gespeichert… + Deine Wallet wird eingerichtet… + Die gefundenen Wallets werden überprüft… + Neue Wallet erstellen + Diese Wallet konnte nicht gelesen werden + Neueste — deine Haupt-Wallet + Mints: %1$s + Du hast mehr als eine Cashu-Wallet im Netzwerk — höchstwahrscheinlich von verschiedenen Apps erstellt. Die neueste wird zu deiner Haupt-Wallet; übertrage alle Guthaben aus den älteren in sie. + Wir haben mehrere Wallets gefunden + Keine wiederherstellbaren Guthaben + Wir konnten auf keinem Relay eine bestehende Cashu-Wallet unter deinem Nostr-Schlüssel finden. Erstelle eine neue, um zu beginnen. + Keine Wallet gefunden + Ältere Wallet + Guthaben in Haupt-Wallet übertragen + %1$s Sats wiederherstellbar + %1$s Sats wiederhergestellt + Erneut suchen + Deine Wallet wird gesucht… + Wir durchsuchen jedes Relay nach einer Cashu-Wallet, die unter deinem Nostr-Schlüssel veröffentlicht wurde. + Relays werden durchsucht… %1$d / %2$d + Als Haupt-Wallet festlegen + Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren. + Diese Cashu-Wallet ist im Nostr-Netzwerk unter deinem Schlüssel veröffentlicht. Nutze sie auf diesem Gerät — wir senden sie erneut an deine Relays, damit sie beim nächsten Mal leicht zu finden ist. + Wir haben deine Wallet gefunden + Richte deine Wallet ein + Diese Wallet verwenden + Wird am Ende der Nachricht angefügt, wenn du einen neuen Beitrag, eine Antwort, ein Zitat oder einen Artikel öffnest. Leer lassen, um zu deaktivieren. + Deine Signatur + Signatur + AMRAP + Zirkel + EMOM + Schriftart + Schrift, die in der gesamten App verwendet wird + Monospace + Serifenlos + Serif + Systemstandard + Schriftgröße + Skaliert die Textgröße in der gesamten App + Riesig + Groß + Normal + Klein + Branch + Commit + Binärdatei wird nicht angezeigt. + Issue schließen + Wieder öffnen + Merge-Basis + Beschreibung + Neu + Abbrechen + Erstellen + Labels + Bug, Verbesserung… + Titel + Neues Issue + Änderungen erneut laden + Änderungen werden geladen… + Keine Dateiänderungen gefunden. + Überarbeitet + Der Pull-Request wurde auf einen neuen Commit aktualisiert. + Änderungen ansehen + Binärdatei (%1$s) — keine Vorschau verfügbar. + Repository als Lesezeichen speichern + Das Repository konnte nicht von seiner Klon-URL geladen werden. + Repository wird geladen… + Dateiinhalt kopieren + Dieser Ordner ist leer. + Dieses Repository kann nicht über http(s) geklont werden, daher ist die Code-Ansicht hier nicht verfügbar. + Extern gehostet + Diese Datei konnte nicht geladen werden. + Geschlossen & erledigt + Offen + Alle + Betreut von + Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen. + Keine passenden Dateien. + Dieses Repository hat keine README-Datei. + Letzte Aktivität + Erneut versuchen + Dateien durchsuchen… + Klon-URLs (eine pro Zeile) + Repository bearbeiten + Themen (durch Komma getrennt) + Web-URLs (eine pro Zeile) + Branches + Tags + Aktualisiert + Tags + Schließen + Als gemergt markieren + Wieder öffnen + Ohne Titel + %1$s… + Nachrichten + Shell + Uploads + nApplet: %1$s + %1$s blockieren und ignorieren + Verbinden + Wie sollen die Anfragen dieser App behandelt werden? + möchte sich mit deinem Nostr-Konto verbinden + Mit Nostr verbinden + Berechtigungen + Bisher haben sich keine Apps verbunden.\n\nWenn sich eine Web-App mit deinem Nostr-Schlüssel verbindet, erscheint sie hier. + Diese App vergessen + Signierungs-Ausnahmen + Signierungs-Vertrauensstufe + apps berechtigungen signierer napplet nsite webapp vertrauen verbunden + Weniger Optionen + Ereignis ausblenden + Mehr Optionen + Ereignis anzeigen + möchte %1$s + Erlauben + Fragen + Verweigern + nApplet %1$s… + deine privaten Nachrichten lesen + eine Nachricht verschlüsseln + Ereignis vom Typ %1$d signieren + für %1$s signieren (Typ: %2$d) + Mich jedes Mal fragen + Vorgangs-Ausnahmen + Alle Berechtigungen widerrufen + Verbundene Apps + Ich vertraue ihr voll + Alle Anfragen automatisch signieren (außer Zahlungen) + Ich bin etwas paranoid + Nichts ohne Nachfrage signieren! + Bleiben wir vernünftig + Die häufigsten Anfragen automatisch genehmigen + Für 24 Stunden erlauben + Für 30 Tage erlauben + Bei keiner Nostr-Anfrage mehr fragen + Einmal erlauben + Nicht mehr fragen für %1$s + Für diese Sitzung erlauben + Verweigern + %1$s immer verweigern + Läuft ab + Zuletzt verwendet + Bisher haben sich keine Apps verbunden. + Alle Berechtigungen widerrufen + nApplets + nSites + Verifizierter Autor + Podcasts + Deine gespeicherten Podcasts und Episoden + von %1$s + Kapitel + Abgeschlossen + Cover hinzufügen + Quadratisches Bild, das für die Episode angezeigt wird + Lege Titel, Cover und Details deiner Show fest + Erstelle deinen Podcast + Episode bearbeiten + Dein Podcast + Audiodatei bereit zum Hochladen + Audiodatei hinzufügen + MP3, M4A oder anderes Audio + Audio-URL + https://…/episode.mp3 + Kapitel-URL + Episode löschen + Diese Episode löschen? Das kann nicht rückgängig gemacht werden. + Dauer (Sekunden) + Weitere Details + Ep %1$d + Episode # + Staffel + Zusammenfassung + Titel + Episodentitel + Themen + durch, Komma, getrennte, Tags + Transkript-URL + Video-URL + Anstößig + Hosts & Gäste + Neue Episode + Neuer Trailer + Noch keine Episoden. Tippe auf Neue Episode, um deine erste zu veröffentlichen. + Highlight abspielen + Premium + Wird veröffentlicht… + Co-Host + Redakteur + Host + Staffel %1$d + S%1$d · E%2$d + Autor + Kategorien + Technik, Nachrichten + Show abgeschlossen (keine weiteren Episoden) + Copyright + Cover hinzufügen + Quadratisches Cover für deine Show + Beschreibung + Kontakt-E-Mail + Anstößige Inhalte + Finanzierungslinks + https://… + Sprache + de + Gesperrt (Premium) + Show-Titel + Mein Podcast + Episodisch + Show-Typ + Serie + Website + Die Show unterstützen + Tippen, um Show-Details zu bearbeiten + Top-Unterstützer + Trailer + Staffel %1$d + Trailer-Titel + Trailer-Clip hinzufügen + Kurze Audio- oder Videovorschau + Medien-URL + Transkript + Ohne Titel + Adresse manuell hinzufügen + Empfänger hinzufügen + Füge Empfänger hinzu, um eingehende Sats nach Gewichtung aufzuteilen. Hörer boosten oder streamen Value an diese Ziele. + Value-for-Value-Fehler + Gebühr + Value-for-Value + Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden. + Lightning-Adresse + name@example.com + Dieser Podcast hat keine zahlbaren Value-Empfänger. + Node-Pubkey + 02abc… (33-Byte-Hex) + Name (optional) + Empfänger entfernen + Nostr-Nutzer hinzufügen + Nach Name oder @Handle suchen + %1$d%% + Sats streamen + Sendet automatisch Value, während du zuhörst. + %1$d Sats/Min + Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen. + %1$d Sats in dieser Sitzung gestreamt + Lightning-Adresse + Node (Keysend) + Dieser Nutzer hat keine Lightning-Adresse + Gewichtung + Zaps hierauf werden aufgeteilt zwischen: + Video + Dein Podcast + Zeitkritische Ereignisse (Relay-Authentifizierung, Zap-Anfragen, Wallet- und Signierer-Nachrichten, Entwürfe, Listen) werden nie gemint. + Was geschürft wird + Kommentare + Antworten auf Artikel, Dateien und andere Inhalte + Verpackungen privater Nachrichten + Ermöglicht DM-Relays das Filtern von Posteingangs-Spam; nur die äußere Verpackung wird gemint, nie deine Nachricht + Langform & Highlights + Artikel und Highlights; selten, die Kosten sind vernachlässigbar + Andere öffentliche Inhalte + Umfragen, Live-Status, Kleinanzeigen und alles andere Öffentliche + Öffentlicher & Live-Chat + Schürf-Verzögerungen stören den Gesprächsfluss + Reaktionen + Häufigster Typ; jedes Like zu schürfen kostet Akku + Meldungen + Relays gewichten Meldungen nach ihren Kosten + Reposts + Hohes Volumen bei aktiven Nutzern + Kurze Notizen & Antworten + Die primäre öffentliche Spam-Fläche + Sprachnachrichten + Öffentliche Sprachbeiträge und -antworten + Feinabstimmung des Ziels in führenden Nullbits. + Benutzerdefinierte Schwierigkeit + ≈ %1$s pro Beitrag auf diesem Gerät + Schürft vor der Veröffentlichung einen NIP-13-Arbeitsnachweis in deine Beiträge, damit Relays und Leser sie gegen Spam gewichten können. Höhere Werte dauern exponentiell länger zum Schürfen. Beiträge werden nach dem Schürfen im Hintergrund veröffentlicht. + Aus + Schwierigkeit + <1 s + Chat-Nachricht + Meldung + Arbeitsnachweis wird geschürft + Abbrechen + Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt. + Arbeitsnachweis-Schürfen + Standard (aus) + Für diesen Beitrag aus + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden: %2$s + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden. Es wird beim nächsten Start der App erneut versucht. + Arbeitsnachweis + ≈ %1$s verbleibend + jeden Moment + Apps & Sites + Bitcoin-(On-Chain-)Wallet + Block %1$d + Leerer Slot + PS1-Memory-Card-Spielstand + Einmal erlauben + Dieses Relay immer erlauben + Meine Nachrichten immer zustellen + Dieses Relay blockieren + Wenn nicht, wird %1$s nicht darüber benachrichtigt. + Wenn nicht, wird deine Nachricht an %1$s nicht gepostet. + Wenn nicht, siehst du hier keine Beiträge von %1$s. + Wenn nicht, siehst du %1$s hier nicht. + Wenn nicht, wird deine Nachricht an %1$s nicht zugestellt. + Wo du dich anmelden willst + Erlauben + Verweigern + Vergessen + Wann authentifiziert wird + Zuletzt verwendet vor %1$s + %1$s und andere + Hier ist noch nichts — deine globale Richtlinie gilt für jedes Relay. + Relay-spezifische Ausnahmen + Immer authentifizieren + Auth-Challenges für jedes Relay signieren, das sie anfordert + Benutzerdefiniert + Wähle genau, bei welchen Relays du dich anmeldest. Bei allem, was du unten nicht erlaubt hast, wirst du gefragt. + Nie authentifizieren + Auth-Challenges von allen Relays ignorieren + Dieses Relay möchte zuerst bestätigen, dass du es wirklich bist. Sein Betreiber sieht, welches Konto du bist. + Diesem Relay bestätigen, dass du es bist? + Mit deinem eigenen Relay verbinden + Benachrichtigen: + Dieses Relay verwenden + In diesem Raum posten + Beiträge herunterladen von: + Diesen Raum öffnen + Deine private Nachricht senden an: + auth authentifizierung relay signieren verifizieren nip-42 identität + Relay-Authentifizierung + %1$s benachrichtigen? + In %1$s posten? + Beiträge von %1$s laden? + %1$s öffnen? + Deine Nachricht an %1$s senden? + Personen, denen ich folge, Nachrichten senden + Bei den Relays einer gefolgten Person anmelden, um DMs, Antworten und Benachrichtigungen zu senden. + Jedem Nachrichten senden + Bei den Relays von Fremden anmelden, um DMs, Antworten und Benachrichtigungen zu senden. Standardmäßig aus; stattdessen wirst du jedes Mal gefragt. + Meine Relays und Räume + Bei deinen eigenen Relays und bei öffentlichen Chats, Communitys und Livestreams anmelden, denen du beigetreten bist oder die du favorisiert hast. + Beiträge von Personen lesen, denen ich folge + Bei den Relays einer gefolgten Person anmelden, um deren Beiträge herunterzuladen. + Foto hinzufügen + Nur mit Einladung + Privat + Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus. + Durchsuchen + Gib eine gültige Relay-URL ein (wss://…). + Beliebte Relays + Relay-Adresse + Gruppen finden + Relays, auf denen du bist + Foto ändern + Noch keine Gruppen auf diesem Relay. + Erstellen + Nur mit Einladung + Gruppenname + Privat (nur Mitglieder lesen) + Eine Gruppe erstellen + Thema (optional) + Rolle entfernen + Es wird nach Gruppen über deine Relays gesucht… + Noch keine Gruppen für diesen Filter gefunden. + Speichern + Nur mit Einladung + Gruppenname + Privat (nur Mitglieder lesen) + Gruppe bearbeiten + Thema (optional) + Dieses Relay favorisieren + Beschreibung + Ort (Geohash) + u0nd + Name + Gruppenbild + Themen + bitcoin, nostr, kunst + Nicht gelistet + Diese Gruppe aus dem öffentlichen Verzeichnis des Relays ausblenden. + Nur mit Einladung + Personen müssen eingeladen oder genehmigt werden, um beizutreten. + Privat + Nur Mitglieder können Nachrichten lesen. + Nur Mitglieder posten + Nur Mitglieder können Nachrichten posten. + Einladungscode (für diese Gruppe nur mit Einladung): + Einladungscode kopiert + Teile diesen Code, damit Personen dieser Gruppe beitreten können. + Einladung wird erstellt… + Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten. + Einladung wird vorbereitet… + Personen einladen + Diese Gruppe ist nur mit Einladung. Gib den Code ein, den du erhalten hast. + Einladungscode + Beitreten + Gruppe beitreten + Tritt dieser Gruppe bei, um Nachrichten zu senden. + Zum Admin machen + Zum Moderator machen + Mitglieder + Gruppe bearbeiten + Mitglieder + Noch keine Nachrichten + Gruppe öffnen + Angefragt + Dieses Relay wirbt nicht mit Unterstützung für NIP-29-Relay-Gruppen. Eine hier erstellte Gruppe funktioniert nicht — ihr Name, ihre Mitglieder und Nachrichten werden nicht vom Relay verwaltet. Wähle ein Relay, das relaybasierte Gruppen unterstützt. + Aus Gruppe entfernen + %1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden. + Admin + Mitglied + Moderator + Entdeckung + Hilf Personen, diese Gruppe zu finden. Themen und ein Ort lassen sie unter den passenden Entdeckungsfiltern erscheinen (falls das Host-Relay sie unterstützt). + Berechtigungen + Relay-Gruppen + Schreibe etwas… + Neuer Thread + Posten + Titel + Ohne Titel + Noch keine Threads. Starte einen mit der +-Schaltfläche. + Threads + Nach Relay + Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht. + Inline + Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats. + NIP-29-Gruppenanzeige + Gruppen + Relay-Gruppen + Repositorys + Deine gespeicherten Git-Repositorys + %1$s km + + %1$d Element + %1$d Elemente + + + %1$d Kapitel + %1$d Kapitel + + + %1$d Trailer + %1$d Trailer + + + %1$d Tag + %1$d Tage + + + %1$d Stunde + %1$d Stunden + + + %1$d Minute + %1$d Minuten + + + %1$d Sekunde + %1$d Sekunden + + + %1$s • schürft bei %2$d Bit + %1$s • schürft bei %2$d Bits + + + Arbeitsnachweis wird geschürft… (%1$d Beitrag in der Warteschlange) + Arbeitsnachweis wird geschürft… (%1$d Beiträge in der Warteschlange) + + + %1$d Bit + %1$d Bits + + + Standard (%1$d Bit) + Standard (%1$d Bits) + + + %1$s • wartet auf Schürfen bei %2$d Bit + %1$s • wartet auf Schürfen bei %2$d Bits + + + %1$d Person, der du folgst + %1$d Personen, denen du folgst + + + %1$d Mitglied + %1$d Mitglieder + + + %1$d Nachricht + %1$d Nachrichten + + + %1$d+ Nachricht + %1$d+ Nachrichten + diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index b12e17d4b0..55fe4fd8ff 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3110,4 +3110,557 @@ Erro ao analisar JSON da busca de fatura %1$s. Verifique a configuração de iluminação do usuário Não foi possível criar uma fatura com raios. Mensagem do %1$s: %2$s Não foi possível criar uma fatura lightning a partir de %1$s: Elemento pr não encontrado no JSON resultante. + Cor de destaque + Azul + Cor principal usada em botões e links + Verde + Laranja + Rosa + Roxo + Vermelho + Categorias + Implementa + App + NIP-%1$s + Relacionados + NIPs suportados + via %1$s + Detecção de pássaros + Apps de pessoas que você segue + Sites de pessoas que você segue + Descobrir web apps + Escolher mints + Agora escolha alguns mints para hospedar seus sats. + Carteira criada + Sua carteira é salva automaticamente conforme você adiciona ou remove mints. Uma chave de nutzap é criada para você na primeira vez que adiciona um mint. + Salvando… + Configurando sua carteira… + Verificando as carteiras que encontramos… + Criar uma nova carteira + Não foi possível ler esta carteira + Mais recente — sua carteira principal + Mints: %1$s + Você tem mais de uma carteira Cashu na rede — provavelmente criadas por apps diferentes. A mais recente se torna sua carteira principal; recupere para ela os fundos das mais antigas. + Encontramos várias carteiras + Nenhum fundo recuperável + Não encontramos uma carteira Cashu existente sob sua chave Nostr em nenhum relay. Crie uma nova para começar. + Nenhuma carteira encontrada + Carteira mais antiga + Recuperar fundos para a carteira principal + %1$s sats recuperáveis + %1$s sats recuperados + Buscar novamente + Procurando sua carteira… + Procurando em todos os relays por uma carteira Cashu publicada sob sua chave Nostr. + Procurando em relays… %1$d / %2$d + Definir como carteira principal + Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas. + Esta carteira Cashu está publicada na rede Nostr sob sua chave. Use-a neste dispositivo — vamos retransmiti-la para seus relays para que seja fácil encontrá-la da próxima vez. + Encontramos sua carteira + Configure sua carteira + Usar esta carteira + Adicionado ao final da mensagem ao abrir uma nova publicação, resposta, citação ou artigo. Deixe em branco para desativar. + Sua assinatura + Assinatura + AMRAP + EMOM + Fonte + Tipo de letra usado em todo o app + Monoespaçada + Sem serifa + Serifada + Padrão do sistema + Tamanho da fonte + Ajuste o tamanho do texto em todo o app + Enorme + Grande + Normal + Pequeno + Branch + Commit + Arquivo binário não exibido. + Fechar issue + Reabrir + Base de merge + Descrição + Nova + Cancelar + Criar + Labels + bug, melhoria… + Título + Nova issue + Tentar carregar alterações novamente + Carregando alterações… + Nenhuma alteração de arquivo encontrada. + Revisado + O pull request foi atualizado para um novo commit. + Ver alterações + Arquivo binário (%1$s) — prévia não disponível. + Marcar repositório + Branches + Não foi possível carregar o repositório a partir da sua URL de clone. + Carregando repositório… + Commits + Copiar conteúdo do arquivo + padrão + Esta pasta está vazia. + Este repositório não pode ser clonado via http(s), então a visualização de código não está disponível aqui. + Hospedado externamente + Não foi possível carregar este arquivo. + Fechados & Resolvidos + Abertos + Todos + Mantido por + Este anúncio de repositório não tem URL de clone http(s) para navegar. + Nenhum histórico de commits disponível. + Nenhum arquivo correspondente. + Abrir no navegador + Texto + Este repositório não tem arquivo README. + Atividade recente + Tentar novamente + raiz + Pesquisar arquivos… + URLs de clone (uma por linha) + Descrição + Nome + Salvar + Editar repositório + Tópicos (separados por vírgula) + URLs web (uma por linha) + Branches + Arquivos + Tags + Atualizado + Código + Readme + Tags + Repositórios Git + Fechar + Marcar como merged + Reabrir + Sem título + Pull Request + Atualização de PR + - admin + Relays + %1$s… + Mensagens + Relays + Shell + Uploads + nApplet: %1$s + Bloquear e ignorar %1$s + Conectar + Como as solicitações deste app devem ser tratadas? + quer se conectar à sua conta Nostr + Conectar ao Nostr + Capacidades + Nenhum app se conectou ainda.\n\nQuando um web app se conectar à sua chave Nostr, ele aparecerá aqui. + Esquecer este app + Substituições de operação de assinatura + Nível de confiança para assinatura + apps permissões assinador napplet nsite webapp confiança conectados + Menos opções + Ocultar evento + Mais opções + Mostrar evento + quer %1$s + Permitir + Perguntar + Negar + nApplet %1$s… + ler suas mensagens privadas + criptografar uma mensagem + assinar evento kind %1$d + assinar para %1$s (kind: %2$d) + Perguntar sempre + Substituições de operação + Revogar todas as permissões + Apps conectados + Confio totalmente + Assinar automaticamente todas as solicitações (exceto pagamentos) + Sou um pouco paranoico + Não assine nada sem me perguntar! + Vamos ser razoáveis + Aprovar automaticamente as solicitações mais comuns + Permitir por 24 horas + Permitir por 30 dias + Não perguntar novamente para nenhuma solicitação Nostr + Permitir uma vez + Não perguntar novamente para %1$s + Permitir nesta sessão + Negar + Sempre negar %1$s + Expira + Usado por último + Nenhum app se conectou ainda. + Revogar todas as permissões + nApplets + nSites + Autor verificado + Podcasts + Seus podcasts e episódios marcados + por %1$s + Capítulos + Concluído + Adicionar capa + Imagem quadrada exibida para o episódio + Configure o título, a arte e os detalhes do seu programa + Crie seu podcast + Editar episódio + Seu podcast + Arquivo de áudio pronto para upload + Adicionar arquivo de áudio + MP3, M4A ou outro áudio + URL do áudio + https://…/episodio.mp3 + URL dos capítulos + Excluir episódio + Excluir este episódio? Isso não pode ser desfeito. + Duração (segundos) + Mais detalhes + Ep %1$d + Episódio nº + Temporada + Resumo + Título + Título do episódio + Tópicos + tags, separadas, por vírgula + URL da transcrição + URL do vídeo + Explícito + Apresentadores & Convidados + Novo episódio + Novo trailer + Nenhum episódio ainda. Toque em Novo episódio para publicar o primeiro. + Reproduzir destaque + Premium + Publicando… + Coapresentador + Editor + Apresentador + Temporada %1$d + T%1$d · E%2$d + Autor + Categorias + Tecnologia, Notícias + Programa concluído (sem mais episódios) + Direitos autorais + Adicionar capa + Arte quadrada para o seu programa + Descrição + E-mail de contato + Conteúdo explícito + Links de financiamento + https://… + Idioma + pt + Bloqueado (premium) + Título do programa + Meu Podcast + Episódico + Tipo de programa + Serial + Site + Apoiar o programa + Toque para editar os detalhes do programa + Principais apoiadores + Trailer + Temporada %1$d + Título do trailer + Adicionar clipe de trailer + Prévia curta de áudio ou vídeo + URL da mídia + Transcrição + Sem título + Adicionar endereço manualmente + Adicionar destinatário + Adicione destinatários para dividir os sats recebidos por peso. Os ouvintes impulsionam ou transmitem valor para esses destinos. + Erro de Value-for-Value + Taxa + Value-for-Value + Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó). + Endereço Lightning + nome@exemplo.com + Este podcast não tem destinatários de valor pagáveis. + Chave pública do nó + 02abc… (33 bytes em hex) + Nome (opcional) + Remover destinatário + Adicionar um usuário Nostr + Pesquise por nome ou @handle + %1$d%% + Transmitir sats + Envia valor automaticamente enquanto você ouve. + %1$d sats/min + Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve. + %1$d sats transmitidos nesta sessão + Endereço Lightning + Nó (keysend) + Este usuário não tem endereço Lightning + Peso + Os zaps para isto são divididos entre: + Vídeo + Seu podcast + Eventos com prazo crítico (autenticação de relay, solicitações de zap, mensagens de carteira e assinador, rascunhos, listas) nunca são minerados. + O que minerar + Comentários + Respostas a artigos, arquivos e outros conteúdos + Envelopes de mensagens privadas + Permite que os relays de DM filtrem spam da caixa de entrada; apenas o envelope externo é minerado, nunca sua mensagem + Conteúdo longo & destaques + Artigos e destaques; pouco frequentes, o custo é insignificante + Outro conteúdo público + Enquetes, status ao vivo, classificados e tudo mais que for público + Chat público & ao vivo + Atrasos de mineração prejudicam o fluxo da conversa + Reações + Kind de maior volume; minerar cada curtida gasta bateria + Denúncias + Os relays ponderam as denúncias pelo seu custo + Republicações + Alto volume para usuários ativos + Notas curtas & respostas + A principal superfície de spam público + Mensagens de voz + Publicações e respostas de voz públicas + Ajuste com precisão o alvo em bits zero à esquerda. + Dificuldade personalizada + ≈ %1$s por publicação neste dispositivo + Minera uma prova de trabalho NIP-13 nas suas publicações antes de publicar, para que relays e leitores possam ponderá-las contra spam. Valores mais altos levam exponencialmente mais tempo para minerar. As publicações são publicadas em segundo plano depois de mineradas. + Desligado + Dificuldade + <1 s + Mensagem de chat + Denúncia + Minerando prova de trabalho + Cancelar + Mostra publicações que ainda estão minerando sua prova de trabalho NIP-13 para que possam terminar depois que você sair do app. + Mineração de prova de trabalho + Padrão (desligado) + Desligado para esta publicação + Seu %1$s terminou de minerar, mas não pôde ser publicado: %2$s + Seu %1$s terminou de minerar, mas não pôde ser publicado. Uma nova tentativa será feita na próxima vez que o app iniciar. + Prova de trabalho + ≈ %1$s restante + a qualquer momento + Apps & Sites + Carteira Bitcoin (on-chain) + bloco %1$d + Espaço vazio + Salvamento em cartão de memória PS1 + Permitir uma vez + Sempre permitir este relay + Sempre entregar minhas mensagens + Bloquear este relay + Se não fizer isso, %1$s não será notificado sobre isto. + Se não fizer isso, sua mensagem para %1$s não será publicada. + Se não fizer isso, você não verá publicações de %1$s aqui. + Se não fizer isso, você não verá %1$s aqui. + Se não fizer isso, sua mensagem para %1$s não será entregue. + Em qual fazer login + Permitir + Negar + Esquecer + Quando autenticar + Usado por último há %1$s + %1$s e outros + Nada aqui ainda — sua política global se aplica a todos os relays. + Substituições por relay + Sempre autenticar + Assinar desafios de autenticação de todos os relays que solicitarem + Personalizado + Escolha exatamente em quais relays fazer login. Você será perguntado sobre qualquer um que não tenha permitido abaixo. + Nunca autenticar + Ignorar desafios de autenticação de todos os relays + Este relay quer confirmar que é realmente você primeiro. Seu operador verá qual conta você é. + Confirmar que é você para este relay? + Conectar ao seu próprio relay + Notificar: + Usar este relay + Publicar nesta sala + Baixar publicações de: + Abrir esta sala + Enviar sua mensagem privada para: + auth autenticação relay assinar verificar nip-42 identidade + Autenticação de relay + Notificar %1$s? + Publicar em %1$s? + Carregar publicações de %1$s? + Abrir %1$s? + Enviar sua mensagem para %1$s? + Enviar mensagem a pessoas que sigo + Fazer login nos relays de quem você segue para enviar DMs, respostas e notificações. + Enviar mensagem a qualquer pessoa + Fazer login nos relays de desconhecidos para enviar DMs, respostas e notificações. Desativado por padrão; em vez disso, você será perguntado a cada vez. + Meus relays e salas + Fazer login nos seus próprios relays e em chats públicos, comunidades e transmissões ao vivo que você entrou ou favoritou. + Ler publicações de pessoas que sigo + Fazer login nos relays de quem você segue para baixar as publicações deles. + Adicionar foto + Somente por convite + Privado + Explore os grupos hospedados em um relay. Cole um endereço de relay ou escolha um abaixo. + Explorar + Insira uma URL de relay válida (wss://…). + Relays populares + Endereço do relay + Encontrar grupos + Relays em que você está + Alterar foto + Nenhum grupo neste relay ainda. + Criar + Somente por convite + Nome do grupo + Privado (leitura somente para membros) + Criar um grupo + Tópico (opcional) + Remover função + Procurando grupos nos seus relays… + Nenhum grupo encontrado para este filtro ainda. + Salvar + Somente por convite + Nome do grupo + Privado (leitura somente para membros) + Editar grupo + Tópico (opcional) + Favoritar este relay + Descrição + Localização (geohash) + u0nd + Nome + Imagem do grupo + Tópicos + bitcoin, nostr, arte + Não listado + Ocultar este grupo do diretório público do relay. + Somente por convite + As pessoas precisam ser convidadas ou aprovadas para entrar. + Privado + Apenas membros podem ler as mensagens. + Publicação somente para membros + Apenas membros podem publicar mensagens. + Código de convite (para este grupo somente por convite): + Código de convite copiado + Compartilhe este código para que as pessoas possam entrar neste grupo. + Criando um convite… + Este grupo é somente por convite — você precisa de um convite para publicar. + Preparando convite… + Convidar pessoas + Este grupo é somente por convite. Insira o código que você recebeu. + Código de convite + Entrar + Entrar no grupo + Entre neste grupo para enviar mensagens. + Tornar admin + Tornar moderador + Membros + Editar grupo + Membros + Nenhuma mensagem ainda + Abrir grupo + Solicitado + Este relay não anuncia suporte a grupos NIP-29. Um grupo criado aqui não funcionará — seu nome, membros e mensagens não serão gerenciados pelo relay. Escolha um relay que suporte grupos baseados em relay. + Remover do grupo + Remover %1$s deste grupo? A pessoa perderá o acesso até ser readicionada ou reconvidada. + Admin + Membro + Moderador + Descoberta + Ajude as pessoas a encontrar este grupo. Tópicos e uma localização fazem-no aparecer nos filtros de descoberta correspondentes (se o relay host os suportar). + Permissões + Grupos de relay + Escreva algo… + Nova thread + Publicar + Título + Sem título + Nenhuma thread ainda. Comece uma com o botão +. + Threads + Por relay + Agrupa os grupos de cada relay em uma única linha, posicionada na sua mensagem mais recente. + Em linha + Mostra cada grupo como sua própria conversa, misturado com seus chats. + Exibição de grupos NIP-29 + Grupos + Grupos de relay + Repositórios + Seus repositórios git marcados + %1$s km + Volume + + %1$d arquivo alterado + %1$d arquivos alterados + + + %1$d item + %1$d itens + + + %1$d msg + %1$d msgs + + + %1$d capítulo + %1$d capítulos + + + %1$d trailer + %1$d trailers + + + %1$d dia + %1$d dias + + + %1$d hora + %1$d horas + + + %1$d minuto + %1$d minutos + + + %1$d segundo + %1$d segundos + + + %1$s • minerando em %2$d bit + %1$s • minerando em %2$d bits + + + Minerando prova de trabalho… (%1$d publicação na fila) + Minerando prova de trabalho… (%1$d publicações na fila) + + + %1$d bit + %1$d bits + + + Padrão (%1$d bit) + Padrão (%1$d bits) + + + %1$s • aguardando para minerar em %2$d bit + %1$s • aguardando para minerar em %2$d bits + + + %1$d pessoa que você segue + %1$d pessoas que você segue + + + %1$d membro + %1$d membros + + + %1$d mensagem + %1$d mensagens + + + %1$d+ mensagem + %1$d+ mensagens + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ca69e77142..fd9d2a11a7 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3115,4 +3115,552 @@ Fel vid tolkning av JSON från %1$ss fakturahämtning. Kontrollera användarens blixtkonfiguration Det gick inte att skapa en blixt faktura. Meddelande från %1$s: %2$s Det gick inte att skapa en blixtfaktura från %1$s: Elementet pr hittades inte i den resulterande JSON. + Accentfärg + Blå + Huvudfärg som används för knappar och länkar + Grön + Orange + Rosa + Lila + Röd + Kategorier + Implementerar + App + NIP-%1$s + Relaterat + NIP:er som stöds + via %1$s + Fågeldetektering + Appar från personer du följer + Webbplatser från personer du följer + Upptäck webbappar + Välj mints + Välj nu några mints som ska hosta dina sats. + Plånbok skapad + Din plånbok sparas automatiskt när du lägger till eller tar bort mints. En nutzap-nyckel skapas åt dig första gången du lägger till en mint. + Sparar… + Konfigurerar din plånbok… + Verifierar plånböckerna vi hittade… + Skapa en ny plånbok + Det gick inte att läsa den här plånboken + Nyast — din huvudplånbok + Mints: %1$s + Du har mer än en Cashu-plånbok i nätverket — troligen skapade av olika appar. Den nyaste blir din huvudplånbok; återhämta eventuella medel från de äldre till den. + Vi hittade flera plånböcker + Inga återvinningsbara medel + Vi kunde inte hitta någon befintlig Cashu-plånbok under din Nostr-nyckel på något relä. Skapa en ny för att komma igång. + Ingen plånbok hittades + Äldre plånbok + Återhämta medel till huvudplånboken + %1$s sats kan återvinnas + Återhämtade %1$s sats + Sök igen + Söker efter din plånbok… + Söker på alla reläer efter en Cashu-plånbok publicerad under din Nostr-nyckel. + Söker på reläer… %1$d / %2$d + Ange som huvudplånbok + Ange din huvudplånbok först, återhämta sedan medel från de äldre. + Den här Cashu-plånboken är publicerad i Nostr-nätverket under din nyckel. Använd den på den här enheten — vi sänder om den till dina reläer så att den är lätt att hitta nästa gång. + Vi hittade din plånbok + Konfigurera din plånbok + Använd den här plånboken + Läggs till i slutet av meddelandet när du öppnar ett nytt inlägg, svar, citat eller artikel. Lämna tomt för att inaktivera. + Din signatur + Signatur + AMRAP + EMOM + Typsnitt + Teckensnitt som används genom hela appen + Monospace + Sans Serif + Serif + Systemstandard + Teckenstorlek + Skala textstorleken i hela appen + Enorm + Stor + Normal + Liten + Gren + Commit + Binär fil visas inte. + Stäng ärende + Öppna igen + Mergebas + Beskrivning + Ny + Avbryt + Skapa + Etiketter + bugg, förbättring… + Titel + Nytt ärende + Försök läsa in ändringar igen + Läser in ändringar… + Inga filändringar hittades. + Reviderad + Uppdaterade pull request till en ny commit. + Visa ändringar + Binär fil (%1$s) — förhandsvisning ej tillgänglig. + Bokmärk repository + Grenar + Det gick inte att läsa in repositoryt från dess klon-URL. + Läser in repository… + Commits + Kopiera filinnehåll + standard + Den här mappen är tom. + Det här repositoryt kan inte klonas över http(s), så kodvyn är inte tillgänglig här. + Hostat externt + Det gick inte att läsa in den här filen. + Stängda & lösta + Öppna + Alla + Underhålls av + Den här repository-annonseringen har ingen http(s)-klon-URL att bläddra i. + Ingen commit-historik tillgänglig. + Inga matchande filer. + Öppna i webbläsare + Text + Det här repositoryt har ingen README-fil. + Senaste aktivitet + Försök igen + rot + Sök filer… + Klon-URL:er (en per rad) + Beskrivning + Namn + Spara + Redigera repository + Ämnen (kommaseparerade) + Webb-URL:er (en per rad) + Grenar + Filer + Taggar + Uppdaterad + Kod + Readme + Taggar + Git-repositories + Stäng + Markera som mergad + Öppna igen + Namnlös + Pull Request + PR-uppdatering + - admin + %1$s… + Meddelanden + Shell + nApplet: %1$s + Blockera och ignorera %1$s + Anslut + Hur ska den här appens förfrågningar hanteras? + vill ansluta till ditt Nostr-konto + Anslut till Nostr + Behörigheter + Inga appar har anslutit ännu.\n\nNär en webbapp ansluter till din Nostr-nyckel visas den här. + Glöm den här appen + Åsidosättningar för signeringsoperationer + Förtroendenivå för signering + appar behörigheter signerare napplet nsite webapp förtroende anslutna + Färre alternativ + Dölj händelse + Fler alternativ + Visa händelse + vill %1$s + Tillåt + Fråga + Neka + nApplet %1$s… + läsa dina privata meddelanden + kryptera ett meddelande + signera en händelse av typ %1$d + signera för %1$s (typ: %2$d) + Fråga mig varje gång + Åsidosättningar för operationer + Återkalla alla behörigheter + Anslutna appar + Jag litar helt på den + Signera automatiskt alla förfrågningar (utom betalningar) + Jag är lite paranoid + Signera inget utan att fråga mig! + Låt oss vara rimliga + Godkänn automatiskt de vanligaste förfrågningarna + Tillåt i 24 timmar + Tillåt i 30 dagar + Fråga inte igen för några Nostr-förfrågningar + Tillåt en gång + Fråga inte igen om att %1$s + Tillåt för den här sessionen + Neka + Neka alltid %1$s + Upphör + Senast använd + Inga appar har anslutit ännu. + Återkalla alla behörigheter + nApplets + nSites + Verifierad författare + Poddar + Dina bokmärkta poddar och avsnitt + av %1$s + Kapitel + Slutförd + Lägg till omslagsbild + Kvadratisk bild som visas för avsnittet + Ange din podds titel, bild och detaljer + Skapa din podd + Redigera avsnitt + Din podd + Ljudfil redo att laddas upp + Lägg till ljudfil + MP3, M4A eller annat ljud + Ljud-URL + https://…/avsnitt.mp3 + Kapitel-URL + Ta bort avsnitt + Ta bort det här avsnittet? Detta kan inte ångras. + Längd (sekunder) + Fler detaljer + Avs %1$d + Avsnitt # + Säsong + Sammanfattning + Titel + Avsnittstitel + Ämnen + komma, separerade, taggar + Transkriptions-URL + Video-URL + Explicit + Värdar & gäster + Nytt avsnitt + Ny trailer + Inga avsnitt ännu. Tryck på Nytt avsnitt för att publicera ditt första. + Spela höjdpunkt + Premium + Publicerar… + Medvärd + Redaktör + Värd + Säsong %1$d + S%1$d · A%2$d + Författare + Kategorier + Teknik, Nyheter + Podden är komplett (inga fler avsnitt) + Upphovsrätt + Lägg till omslagsbild + Kvadratisk bild för din podd + Beskrivning + Kontakt-e-post + Explicit innehåll + Finansieringslänkar + https://… + Språk + sv + Låst (premium) + Poddtitel + Min podd + Fristående avsnitt + Poddtyp + Seriell + Webbplats + Stöd podden + Tryck för att redigera podddetaljer + Främsta supportrar + Trailer + Säsong %1$d + Trailertitel + Lägg till trailerklipp + Kort ljud- eller videoförhandsvisning + Media-URL + Transkription + Namnlös + Lägg till adress manuellt + Lägg till mottagare + Lägg till mottagare för att fördela inkommande sats efter vikt. Lyssnare boostar eller streamar värde till dessa destinationer. + Value-for-Value-fel + Avgift + Value-for-Value + Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder). + Lightning-adress + namn@example.com + Den här podden har inga betalbara värdemottagare. + Nodens publika nyckel + 02abc… (33-byte hex) + Namn (valfritt) + Ta bort mottagare + Lägg till en Nostr-användare + Sök efter namn eller @handtag + %1$d%% + Streama sats + Skickar värde automatiskt medan du lyssnar. + %1$d sats/min + Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar. + Streamade %1$d sats denna session + Lightning-adress + Nod (keysend) + Den här användaren har ingen Lightning-adress + Vikt + Zaps till detta fördelas mellan: + Video + Din podd + Tidskritiska händelser (relä-autentisering, zap-förfrågningar, plånboks- och signerarmeddelanden, utkast, listor) minas aldrig. + Vad som ska minas + Kommentarer + Svar på artiklar, filer och annat innehåll + Omslag för privata meddelanden + Låter DM-reläer filtrera inkorgsspam; endast det yttre omslaget minas, aldrig ditt meddelande + Långform & höjdpunkter + Artiklar och höjdpunkter; sällsynta, kostnaden är försumbar + Annat offentligt innehåll + Omröstningar, livestatus, radannonser och allt annat offentligt + Offentlig & livechatt + Miningfördröjningar skadar konversationsflödet + Reaktioner + Den mest högvolymiga typen; att mina varje gillning kostar batteri + Rapporter + Reläer väger rapporter efter deras kostnad + Reposter + Hög volym för aktiva användare + Korta notiser & svar + Den primära offentliga spamytan + Röstmeddelanden + Offentliga röstinlägg och svar + Finjustera målet i inledande nollbitar. + Anpassad svårighetsgrad + ≈ %1$s per inlägg på den här enheten + Minar ett NIP-13-bevis på arbete i dina inlägg innan de publiceras så att reläer och läsare kan väga dem mot spam. Högre värden tar exponentiellt längre tid att mina. Inlägg publiceras i bakgrunden när de är minade. + Av + Svårighetsgrad + <1 s + Chattmeddelande + Rapport + Minar bevis på arbete + Avbryt + Visar inlägg som fortfarande minar sitt NIP-13-bevis på arbete så att de kan slutföras efter att du lämnat appen. + Mining av bevis på arbete + Standard (av) + Av för det här inlägget + Ditt %1$s slutförde miningen men kunde inte publiceras: %2$s + Ditt %1$s slutförde miningen men kunde inte publiceras. Det görs ett nytt försök nästa gång appen startar. + Bevis på arbete + ≈ %1$s kvar + när som helst nu + Bitcoin-plånbok (on-chain) + block %1$d + Tom plats + PS1-minneskortsparning + Tillåt en gång + Tillåt alltid det här reläet + Leverera alltid mina meddelanden + Blockera det här reläet + Om du inte gör det meddelas inte %1$s om detta. + Om du inte gör det publiceras inte ditt meddelande till %1$s. + Om du inte gör det ser du inte inlägg från %1$s här. + Om du inte gör det ser du inte %1$s här. + Om du inte gör det levereras inte ditt meddelande till %1$s. + Vad du ska logga in på + Tillåt + Neka + Glöm + När autentisering ska ske + Senast använd för %1$s sedan + %1$s med flera + Inget här ännu — din globala policy gäller för varje relä. + Åsidosättningar per relä + Autentisera alltid + Signera autentiseringsutmaningar för varje relä som begär det + Anpassad + Välj exakt vilka reläer du vill logga in på. Du tillfrågas om allt du inte har tillåtit nedan. + Autentisera aldrig + Ignorera autentiseringsutmaningar från alla reläer + Det här reläet vill först bekräfta att det verkligen är du. Dess operatör ser vilket konto du är. + Bekräfta att det är du för det här reläet? + Anslut till ditt eget relä + Meddela: + Använd det här reläet + Publicera till det här rummet + Ladda ner inlägg från: + Öppna det här rummet + Skicka ditt privata meddelande till: + auth autentisering relä signera verifiera nip-42 identitet + Reläautentisering + Meddela %1$s? + Publicera till %1$s? + Läs in inlägg från %1$s? + Öppna %1$s? + Skicka ditt meddelande till %1$s? + Meddela personer jag följer + Logga in på en följd persons reläer för att skicka DM, svar och notiser. + Meddela vem som helst + Logga in på främlingars reläer för att skicka DM, svar och notiser. Av som standard; du tillfrågas i stället varje gång. + Mina reläer och platser + Logga in på dina egna reläer och på offentliga chattar, communities och livestreams som du har gått med i eller favoritmarkerat. + Läs inlägg från personer jag följer + Logga in på en följd persons reläer för att ladda ner deras inlägg. + Lägg till foto + Endast inbjudan + Privat + Bläddra bland grupperna som hostas på ett relä. Klistra in en reläadress eller välj en nedan. + Bläddra + Ange en giltig relä-URL (wss://…). + Populära reläer + Reläadress + Hitta grupper + Reläer du är på + Ändra foto + Inga grupper på det här reläet ännu. + Skapa + Endast inbjudan + Gruppnamn + Privat (läsning endast för medlemmar) + Skapa en grupp + Ämne (valfritt) + Ta bort roll + Söker efter grupper på dina reläer… + Inga grupper hittades för det här filtret ännu. + Spara + Endast inbjudan + Gruppnamn + Privat (läsning endast för medlemmar) + Redigera grupp + Ämne (valfritt) + Favoritmarkera det här reläet + Beskrivning + Plats (geohash) + u0nd + Namn + Gruppbild + Ämnen + bitcoin, nostr, konst + Ej listad + Dölj den här gruppen från reläets offentliga katalog. + Endast inbjudan + Personer måste bjudas in eller godkännas för att gå med. + Privat + Endast medlemmar kan läsa meddelanden. + Publicering endast för medlemmar + Endast medlemmar kan publicera meddelanden. + Inbjudningskod (för den här gruppen med endast inbjudan): + Inbjudningskod kopierad + Dela den här koden så att personer kan gå med i gruppen. + Skapar en inbjudan… + Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera. + Förbereder inbjudan… + Bjud in personer + Den här gruppen är endast för inbjudna. Ange koden du fick. + Inbjudningskod + Gå med + Gå med i grupp + Gå med i den här gruppen för att skicka meddelanden. + Gör till admin + Gör till moderator + Medlemmar + Redigera grupp + Medlemmar + Inga meddelanden ännu + Öppna grupp + Begärd + Det här reläet annonserar inte stöd för NIP-29-relägrupper. En grupp som skapas här fungerar inte — dess namn, medlemmar och meddelanden hanteras inte av reläet. Välj ett relä som stöder reläbaserade grupper. + Ta bort från grupp + Ta bort %1$s från den här gruppen? De förlorar åtkomst tills de läggs till eller bjuds in igen. + Admin + Medlem + Moderator + Upptäckt + Hjälp personer att hitta den här gruppen. Ämnen och en plats gör att den syns under matchande upptäcktsfilter (om värdreläet stöder dem). + Behörigheter + Relägrupper + Skriv något… + Ny tråd + Publicera + Titel + Namnlös + Inga trådar ännu. Starta en med +-knappen. + Trådar + Efter relä + Slå ihop varje reläs grupper till en enda rad, placerad vid dess nyaste meddelande. + Inline + Visa varje grupp som en egen konversation, blandad med dina chattar. + Visning av NIP-29-grupper + Grupper + Relägrupper + Repositories + Dina bokmärkta git-repositories + %1$s km + + %1$d fil ändrad + %1$d filer ändrade + + + %1$d objekt + %1$d objekt + + + %1$d medd + %1$d medd + + + %1$d kapitel + %1$d kapitel + + + %1$d trailer + %1$d trailers + + + %1$d dag + %1$d dagar + + + %1$d timme + %1$d timmar + + + %1$d minut + %1$d minuter + + + %1$d sekund + %1$d sekunder + + + %1$s • minar vid %2$d bit + %1$s • minar vid %2$d bitar + + + Minar bevis på arbete… (%1$d inlägg i kön) + Minar bevis på arbete… (%1$d inlägg i kön) + + + %1$d bit + %1$d bitar + + + Standard (%1$d bit) + Standard (%1$d bitar) + + + %1$s • väntar på att minas vid %2$d bit + %1$s • väntar på att minas vid %2$d bitar + + + %1$d person du följer + %1$d personer du följer + + + %1$d medlem + %1$d medlemmar + + + %1$d meddelande + %1$d meddelanden + + + %1$d+ meddelande + %1$d+ meddelanden + From 2edb22783cb392a92c8183b5c6bd56104c7cb83b Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:32:20 +0000 Subject: [PATCH 063/206] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 581 ------------------ .../src/main/res/values-de-rDE/strings.xml | 525 ---------------- .../src/main/res/values-fr-rFR/strings.xml | 49 ++ .../src/main/res/values-hi-rIN/strings.xml | 148 ++++- .../src/main/res/values-pl-rPL/strings.xml | 128 ++++ .../src/main/res/values-pt-rBR/strings.xml | 553 ----------------- .../src/main/res/values-sv-rSE/strings.xml | 548 ----------------- docs/changelog/translators.json | 24 +- 8 files changed, 334 insertions(+), 2222 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 38b02a8e34..f826000683 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3198,585 +3198,4 @@ Chyba při analýze JSON z načtení faktury %1$s. Zkontrolujte uživatelské bleskové nastavení Nelze vytvořit bleskovou fakturu. Zpráva od %1$s: %2$s Nelze vytvořit bleskovou fakturu z %1$s: Prvek pr nebyl nalezen ve výsledném JSON. - - Barva zvýraznění - Modrá - Hlavní barva používaná napříč tlačítky a odkazy - Zelená - Oranžová - Růžová - Fialová - Červená - Kategorie - Implementuje - Aplikace - NIP-%1$s - Související - Podporované NIP - prostřednictvím %1$s - Detekce ptáků - Aplikace od lidí, které sledujete - Weby od lidí, které sledujete - Objevujte webové aplikace - Vyberte mincovny - Nyní vyberte několik mincoven, které budou hostit vaše sats. - Peněženka vytvořena - Vaše peněženka se ukládá automaticky, jak přidáváte nebo odebíráte mincovny. Klíč nutzap se pro vás vytvoří při prvním přidání mincovny. - Ukládání… - Nastavování vaší peněženky… - Ověřování peněženek, které jsme našli… - Vytvořit novou peněženku - Tuto peněženku nelze přečíst - Nejnovější — vaše hlavní peněženka - Mincovny: %1$s - Máte v síti více než jednu peněženku Cashu — nejspíše vytvořené různými aplikacemi. Nejnovější se stane vaší hlavní peněženkou; převeďte do ní veškeré prostředky ze starších. - Našli jsme několik peněženek - Žádné obnovitelné prostředky - Na žádném relay jsme nenašli existující peněženku Cashu pod vaším klíčem Nostr. Začněte vytvořením nové. - Žádná peněženka nenalezena - Starší peněženka - Obnovit prostředky do hlavní peněženky - %1$s sats k obnovení - Obnoveno %1$s sats - Hledat znovu - Hledání vaší peněženky… - Prohledáváme každý relay a hledáme peněženku Cashu publikovanou pod vaším klíčem Nostr. - Prohledávání relayů… %1$d / %2$d - Nastavit jako hlavní peněženku - Nejprve nastavte hlavní peněženku, poté obnovte prostředky ze starších. - Tato peněženka Cashu je publikována v síti Nostr pod vaším klíčem. Používejte ji na tomto zařízení — znovu ji rozešleme na vaše relaye, aby se příště snadno našla. - Našli jsme vaši peněženku - Nastavte si peněženku - Použít tuto peněženku - Přidá se na konec zprávy při otevírání nového příspěvku, odpovědi, citace nebo článku. Ponechte prázdné pro vypnutí. - Váš podpis - Podpis - AMRAP - EMOM - Písmo - Typ písma používaný v celé aplikaci - Neproporcionální - Bezpatkové - Patkové - Výchozí systémové - Velikost písma - Změna velikosti textu v celé aplikaci - Obrovská - Velká - Normální - Malá - Větev - Commit - Binární soubor se nezobrazuje. - Uzavřít problém - Znovu otevřít - Základ sloučení - Popis - Nový - Zrušit - Vytvořit - Štítky - bug, vylepšení… - Název - Nový problém - Zkusit načíst změny znovu - Načítání změn… - Nebyly nalezeny žádné změny souborů. - Upraveno - Pull request byl aktualizován na nový commit. - Zobrazit změny - Binární soubor (%1$s) — náhled není k dispozici. - Přidat repozitář do záložek - Větve - Repozitář se nepodařilo načíst z jeho klonovací URL. - Načítání repozitáře… - Commity - Kopírovat obsah souboru - výchozí - Tato složka je prázdná. - Tento repozitář nelze klonovat přes http(s), takže zde není zobrazení kódu k dispozici. - Hostováno externě - Tento soubor se nepodařilo načíst. - Uzavřené a vyřešené - Otevřené - Vše - Spravuje - Toto oznámení repozitáře neobsahuje žádnou http(s) klonovací URL k procházení. - Není k dispozici žádná historie commitů. - Žádné odpovídající soubory. - Otevřít v prohlížeči - Text - Tento repozitář nemá soubor README. - Nedávná aktivita - Zkusit znovu - kořen - Hledat soubory… - Klonovací URL (jedna na řádek) - Popis - Název - Uložit - Upravit repozitář - Témata (oddělená čárkami) - Webové URL (jedna na řádek) - Větve - Soubory - Značky - Aktualizováno - Kód - Readme - Značky - Git repozitáře - Uzavřít - Označit jako sloučené - Znovu otevřít - Bez názvu - Pull request - Aktualizace PR - Relaye - %1$s… - Zprávy - Relaye - Shell - nApplet: %1$s - Zablokovat a ignorovat %1$s - Připojit - Jak se mají zpracovávat požadavky této aplikace? - se chce připojit k vašemu účtu Nostr - Připojit k Nostr - Oprávnění - Zatím se nepřipojily žádné aplikace.\n\nAž se webová aplikace připojí k vašemu klíči Nostr, objeví se zde. - Zapomenout tuto aplikaci - Výjimky pro podepisovací operace - Úroveň důvěry pro podepisování - aplikace oprávnění podepisovač napplet nsite webová aplikace důvěra připojené - Méně možností - Skrýt událost - Více možností - Zobrazit událost - chce %1$s - Povolit - Zeptat se - Odmítnout - nApplet %1$s… - číst vaše soukromé zprávy - zašifrovat zprávu - podepsat událost druhu %1$d - podepsat pro %1$s (druh: %2$d) - Zeptat se pokaždé - Výjimky operací - Odvolat všechna oprávnění - Připojené aplikace - Plně jí důvěřuji - Automaticky podepisovat všechny požadavky (kromě plateb) - Jsem trochu paranoidní - Nepodepisuj nic, aniž by ses mě zeptal! - Buďme rozumní - Automaticky schvalovat nejběžnější požadavky - Povolit na 24 hodin - Povolit na 30 dní - Znovu se neptat na žádné požadavky Nostr - Povolit jednou - Znovu se neptat na %1$s - Povolit pro tuto relaci - Odmítnout - Vždy odmítnout %1$s - Vyprší - Naposledy použito - Zatím se nepřipojily žádné aplikace. - Odvolat všechna oprávnění - nApplety - nSites - Ověřený autor - Podcasty - Vaše podcasty a epizody v záložkách - od %1$s - Kapitoly - Dokončeno - Přidat obal - Čtvercový obrázek zobrazený u epizody - Nastavte název, obal a podrobnosti svého pořadu - Vytvořte svůj podcast - Upravit epizodu - Váš podcast - Zvukový soubor připraven k nahrání - Přidat zvukový soubor - MP3, M4A nebo jiný zvuk - URL zvuku - https://…/epizoda.mp3 - URL kapitol - Smazat epizodu - Smazat tuto epizodu? Tuto akci nelze vrátit zpět. - Délka (sekundy) - Více podrobností - Ep %1$d - Epizoda č. - Série - Shrnutí - Název - Název epizody - Témata - čárkami, oddělené, štítky - URL přepisu - URL videa - Explicitní - Moderátoři a hosté - Nová epizoda - Nová upoutávka - Zatím žádné epizody. Klepnutím na Nová epizoda publikujte svou první. - Přehrát ukázku - Prémiový - Publikování… - Spolumoderátor - Editor - Moderátor - Série %1$d - S%1$d · E%2$d - Autor - Kategorie - Technologie, Zprávy - Pořad kompletní (žádné další epizody) - Autorská práva - Přidat obal - Čtvercový obal vašeho pořadu - Popis - Kontaktní e-mail - Explicitní obsah - Odkazy na financování - https://… - Jazyk - cs - Uzamčeno (prémiové) - Název pořadu - Můj podcast - Epizodický - Typ pořadu - Seriálový - Web - Podpořte pořad - Klepnutím upravíte podrobnosti pořadu - Nejlepší podporovatelé - Upoutávka - Série %1$d - Název upoutávky - Přidat klip upoutávky - Krátký zvukový nebo video náhled - URL média - Přepis - Bez názvu - Přidat adresu ručně - Přidat příjemce - Přidejte příjemce pro rozdělení příchozích sats podle váhy. Posluchači posílají boost nebo streamují hodnotu do těchto cílů. - Chyba Value-for-Value - Poplatek - Value-for-Value - Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel). - Lightning adresa - jmeno@example.com - Tento podcast nemá žádné platitelné příjemce hodnoty. - Veřejný klíč uzlu - 02abc… (33bajtový hex) - Jméno (volitelné) - Odebrat příjemce - Přidat uživatele Nostr - Hledat podle jména nebo @handle - %1$d%% - Streamovat sats - Automaticky posílá hodnotu, když posloucháte. - %1$d sats/min - Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu. - V této relaci odesláno %1$d sats - Lightning adresa - Uzel (keysend) - Tento uživatel nemá Lightning adresu - Váha - Zapy na toto se rozdělují mezi: - Video - Váš podcast - Časově kritické události (autentizace relay, žádosti o zap, zprávy peněženky a podepisovače, koncepty, seznamy) se nikdy netěží. - Co těžit - Komentáře - Odpovědi na články, soubory a jiný obsah - Obálky soukromých zpráv - Umožňuje DM relayům filtrovat spam ve schránce; těží se pouze vnější obálka, nikdy vaše zpráva - Dlouhé texty a zvýraznění - Články a zvýraznění; zřídkavé, náklady jsou zanedbatelné - Jiný veřejný obsah - Ankety, živé statusy, inzeráty a vše ostatní veřejné - Veřejný a živý chat - Prodlevy při těžení narušují plynulost konverzace - Reakce - Nejobjemnější druh; těžení každého lajku stojí baterii - Nahlášení - Relaye váží nahlášení podle jejich nákladů - Přeposlání - Vysoký objem pro aktivní uživatele - Krátké poznámky a odpovědi - Hlavní veřejná plocha pro spam - Hlasové zprávy - Veřejné hlasové příspěvky a odpovědi - Jemné doladění cíle v počtu úvodních nulových bitů. - Vlastní obtížnost - ≈ %1$s na příspěvek na tomto zařízení - Vytěží do vašich příspěvků NIP-13 proof of work před publikováním, aby je relaye a čtenáři mohli zvážit oproti spamu. Vyšší hodnoty trvají exponenciálně déle. Příspěvky se po vytěžení publikují na pozadí. - Vypnuto - Obtížnost - <1 s - Chatová zpráva - Nahlášení - Těžení proof of work - Zrušit - Zobrazuje příspěvky, které stále těží svůj NIP-13 proof of work, aby mohly dokončit těžbu i po opuštění aplikace. - Těžení proof of work - Výchozí (vypnuto) - Vypnuto pro tento příspěvek - Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat: %2$s - Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat. Bude to zkuseno znovu při příštím spuštění aplikace. - Proof of Work - ≈ %1$s zbývá - každou chvíli - Bitcoin (on-chain) peněženka - blok %1$d - Prázdný slot - Uložení na paměťovou kartu PS1 - Povolit jednou - Vždy povolit tento relay - Vždy doručovat mé zprávy - Zablokovat tento relay - Pokud tak neučiníte, %1$s o tom nebude upozorněn. - Pokud tak neučiníte, vaše zpráva na %1$s nebude odeslána. - Pokud tak neučiníte, neuvidíte zde příspěvky od %1$s. - Pokud tak neučiníte, neuvidíte zde %1$s. - Pokud tak neučiníte, vaše zpráva pro %1$s nebude doručena. - Kam se přihlásit - Povolit - Odmítnout - Zapomenout - Kdy se ověřovat - Naposledy použito před %1$s - %1$s a další - Zatím zde nic není — na každý relay se vztahuje vaše globální zásada. - Výjimky pro jednotlivé relaye - Vždy se ověřovat - Podepisovat autentizační výzvy pro každý relay, který o to požádá - Vlastní - Vyberte přesně, ke kterým relayům se přihlásit. Na cokoli, co jste níže nepovolili, budete dotázáni. - Nikdy se neověřovat - Ignorovat autentizační výzvy ze všech relayů - Tento relay si nejprve chce ověřit, že jste to opravdu vy. Jeho provozovatel uvidí, který účet jste. - Potvrdit tomuto relay, že jste to vy? - Připojit se ke svému vlastnímu relay - Upozornit: - Použít tento relay - Přispět do této místnosti - Stáhnout příspěvky z: - Otevřít tuto místnost - Odeslat vaši soukromou zprávu pro: - auth autentizace relay podepsat ověřit nip-42 identita - Autentizace relay - Upozornit %1$s? - Přispět do %1$s? - Načíst příspěvky z %1$s? - Otevřít %1$s? - Odeslat vaši zprávu pro %1$s? - Zprávy lidem, které sleduji - Přihlásit se k relayům sledovaného pro odesílání DM, odpovědí a upozornění. - Zprávy komukoli - Přihlásit se k relayům cizích lidí pro odesílání DM, odpovědí a upozornění. Ve výchozím nastavení vypnuto; místo toho budete dotázáni pokaždé. - Mé relaye a místnosti - Přihlásit se ke svým vlastním relayům a k veřejným chatům, komunitám a živým vysíláním, ke kterým jste se připojili nebo je oblíbili. - Číst příspěvky od lidí, které sleduji - Přihlásit se k relayům sledovaného pro stažení jeho příspěvků. - Přidat fotku - Pouze na pozvání - Soukromá - Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže. - Procházet - Zadejte platnou URL relay (wss://…). - Oblíbené relaye - Adresa relay - Najít skupiny - Relaye, na kterých jste - Změnit fotku - Na tomto relay zatím nejsou žádné skupiny. - Vytvořit - Pouze na pozvání - Název skupiny - Soukromá (čtení jen pro členy) - Vytvořit skupinu - Téma (volitelné) - Odebrat roli - Hledání skupin napříč vašimi relayi… - Pro tento filtr zatím nebyly nalezeny žádné skupiny. - Uložit - Pouze na pozvání - Název skupiny - Soukromá (čtení jen pro členy) - Upravit skupinu - Téma (volitelné) - Oblíbit tento relay - Popis - Poloha (geohash) - u0nd - Název - Obrázek skupiny - Témata - bitcoin, nostr, umění - Neuvedená - Skrýt tuto skupinu z veřejného adresáře relay. - Pouze na pozvání - Lidé musí být pozváni nebo schváleni, aby se mohli připojit. - Soukromá - Zprávy mohou číst pouze členové. - Přispívání jen pro členy - Zprávy mohou psát pouze členové. - Kód pozvánky (pro tuto skupinu na pozvání): - Kód pozvánky zkopírován - Sdílejte tento kód, aby se lidé mohli připojit k této skupině. - Vytváření pozvánky… - Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku. - Příprava pozvánky… - Pozvat lidi - Tato skupina je pouze na pozvání. Zadejte kód, který jste dostali. - Kód pozvánky - Připojit se - Připojit se ke skupině - Připojte se k této skupině pro odesílání zpráv. - Udělat správcem - Udělat moderátorem - Členové - Upravit skupinu - Členové - Zatím žádné zprávy - Otevřít skupinu - Požádáno - Tento relay neuvádí podporu skupin NIP-29. Skupina zde vytvořená nebude fungovat — její název, členové a zprávy nebudou relayem spravovány. Vyberte relay, který podporuje skupiny založené na relay. - Odebrat ze skupiny - Odebrat %1$s z této skupiny? Ztratí přístup, dokud nebude znovu přidán nebo pozván. - Správce - Člen - Moderátor - Objevování - Pomozte lidem najít tuto skupinu. Témata a poloha ji zobrazí pod odpovídajícími filtry objevování (pokud je hostitelský relay podporuje). - Oprávnění - Skupiny relay - Napište něco… - Nové vlákno - Přispět - Název - Bez názvu - Zatím žádná vlákna. Založte jedno tlačítkem +. - Vlákna - Podle relay - Sbalí skupiny každého relay do jednoho řádku, umístěného u jeho nejnovější zprávy. - V řadě - Zobrazí každou skupinu jako vlastní konverzaci, promíchanou s vašimi chaty. - Zobrazení skupin NIP-29 - Skupiny - Skupiny relay - Repozitáře - Vaše git repozitáře v záložkách - %1$s km - - - %1$d soubor změněn - %1$d soubory změněny - %1$d souboru změněno - %1$d souborů změněno - - - %1$d položka - %1$d položky - %1$d položky - %1$d položek - - - %1$d kapitola - %1$d kapitoly - %1$d kapitoly - %1$d kapitol - - - %1$d upoutávka - %1$d upoutávky - %1$d upoutávky - %1$d upoutávek - - - %1$d den - %1$d dny - %1$d dne - %1$d dní - - - %1$d hodina - %1$d hodiny - %1$d hodiny - %1$d hodin - - - %1$d minuta - %1$d minuty - %1$d minuty - %1$d minut - - - %1$d sekunda - %1$d sekundy - %1$d sekundy - %1$d sekund - - - %1$s • těžení na %2$d bitu - %1$s • těžení na %2$d bitech - %1$s • těžení na %2$d bitu - %1$s • těžení na %2$d bitech - - - Těžení proof of work… (%1$d příspěvek ve frontě) - Těžení proof of work… (%1$d příspěvky ve frontě) - Těžení proof of work… (%1$d příspěvku ve frontě) - Těžení proof of work… (%1$d příspěvků ve frontě) - - - %1$d bit - %1$d bity - %1$d bitu - %1$d bitů - - - Výchozí (%1$d bit) - Výchozí (%1$d bity) - Výchozí (%1$d bitu) - Výchozí (%1$d bitů) - - - %1$s • čeká na těžbu na %2$d bitu - %1$s • čeká na těžbu na %2$d bitech - %1$s • čeká na těžbu na %2$d bitu - %1$s • čeká na těžbu na %2$d bitech - - - %1$d osoba, kterou sledujete - %1$d osoby, které sledujete - %1$d osoby, které sledujete - %1$d osob, které sledujete - - - %1$d člen - %1$d členové - %1$d člena - %1$d členů - - - %1$d zpráva - %1$d zprávy - %1$d zprávy - %1$d zpráv - - - %1$d+ zpráva - %1$d+ zprávy - %1$d+ zprávy - %1$d+ zpráv - diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 8b9f878534..1f23f55b23 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3140,529 +3140,4 @@ Fehler beim Parsen von JSON von %1$s. Überprüfen Sie die Blitzeinrichtung des Benutzers Blitzrechnung konnte nicht erstellt werden. Nachricht von %1$s: %2$s Kann keine Blitzrechnung von %1$s erstellen: Element pr nicht im resultierenden JSON gefunden. - Akzentfarbe - Blau - Hauptfarbe für Schaltflächen und Links - Grün - Orange - Pink - Lila - Rot - Implementiert - App - NIP-%1$s - Verwandt - Unterstützte NIPs - über %1$s - Vogelerkennung - Apps von Personen, denen du folgst - Sites von Personen, denen du folgst - Web-Apps entdecken - Mints auswählen - Wähle nun ein paar Mints, die deine Sats verwahren. - Wallet erstellt - Deine Wallet wird automatisch gespeichert, während du Mints hinzufügst oder entfernst. Ein Nutzap-Schlüssel wird für dich erstellt, sobald du das erste Mint hinzufügst. - Wird gespeichert… - Deine Wallet wird eingerichtet… - Die gefundenen Wallets werden überprüft… - Neue Wallet erstellen - Diese Wallet konnte nicht gelesen werden - Neueste — deine Haupt-Wallet - Mints: %1$s - Du hast mehr als eine Cashu-Wallet im Netzwerk — höchstwahrscheinlich von verschiedenen Apps erstellt. Die neueste wird zu deiner Haupt-Wallet; übertrage alle Guthaben aus den älteren in sie. - Wir haben mehrere Wallets gefunden - Keine wiederherstellbaren Guthaben - Wir konnten auf keinem Relay eine bestehende Cashu-Wallet unter deinem Nostr-Schlüssel finden. Erstelle eine neue, um zu beginnen. - Keine Wallet gefunden - Ältere Wallet - Guthaben in Haupt-Wallet übertragen - %1$s Sats wiederherstellbar - %1$s Sats wiederhergestellt - Erneut suchen - Deine Wallet wird gesucht… - Wir durchsuchen jedes Relay nach einer Cashu-Wallet, die unter deinem Nostr-Schlüssel veröffentlicht wurde. - Relays werden durchsucht… %1$d / %2$d - Als Haupt-Wallet festlegen - Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren. - Diese Cashu-Wallet ist im Nostr-Netzwerk unter deinem Schlüssel veröffentlicht. Nutze sie auf diesem Gerät — wir senden sie erneut an deine Relays, damit sie beim nächsten Mal leicht zu finden ist. - Wir haben deine Wallet gefunden - Richte deine Wallet ein - Diese Wallet verwenden - Wird am Ende der Nachricht angefügt, wenn du einen neuen Beitrag, eine Antwort, ein Zitat oder einen Artikel öffnest. Leer lassen, um zu deaktivieren. - Deine Signatur - Signatur - AMRAP - Zirkel - EMOM - Schriftart - Schrift, die in der gesamten App verwendet wird - Monospace - Serifenlos - Serif - Systemstandard - Schriftgröße - Skaliert die Textgröße in der gesamten App - Riesig - Groß - Normal - Klein - Branch - Commit - Binärdatei wird nicht angezeigt. - Issue schließen - Wieder öffnen - Merge-Basis - Beschreibung - Neu - Abbrechen - Erstellen - Labels - Bug, Verbesserung… - Titel - Neues Issue - Änderungen erneut laden - Änderungen werden geladen… - Keine Dateiänderungen gefunden. - Überarbeitet - Der Pull-Request wurde auf einen neuen Commit aktualisiert. - Änderungen ansehen - Binärdatei (%1$s) — keine Vorschau verfügbar. - Repository als Lesezeichen speichern - Das Repository konnte nicht von seiner Klon-URL geladen werden. - Repository wird geladen… - Dateiinhalt kopieren - Dieser Ordner ist leer. - Dieses Repository kann nicht über http(s) geklont werden, daher ist die Code-Ansicht hier nicht verfügbar. - Extern gehostet - Diese Datei konnte nicht geladen werden. - Geschlossen & erledigt - Offen - Alle - Betreut von - Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen. - Keine passenden Dateien. - Dieses Repository hat keine README-Datei. - Letzte Aktivität - Erneut versuchen - Dateien durchsuchen… - Klon-URLs (eine pro Zeile) - Repository bearbeiten - Themen (durch Komma getrennt) - Web-URLs (eine pro Zeile) - Branches - Tags - Aktualisiert - Tags - Schließen - Als gemergt markieren - Wieder öffnen - Ohne Titel - %1$s… - Nachrichten - Shell - Uploads - nApplet: %1$s - %1$s blockieren und ignorieren - Verbinden - Wie sollen die Anfragen dieser App behandelt werden? - möchte sich mit deinem Nostr-Konto verbinden - Mit Nostr verbinden - Berechtigungen - Bisher haben sich keine Apps verbunden.\n\nWenn sich eine Web-App mit deinem Nostr-Schlüssel verbindet, erscheint sie hier. - Diese App vergessen - Signierungs-Ausnahmen - Signierungs-Vertrauensstufe - apps berechtigungen signierer napplet nsite webapp vertrauen verbunden - Weniger Optionen - Ereignis ausblenden - Mehr Optionen - Ereignis anzeigen - möchte %1$s - Erlauben - Fragen - Verweigern - nApplet %1$s… - deine privaten Nachrichten lesen - eine Nachricht verschlüsseln - Ereignis vom Typ %1$d signieren - für %1$s signieren (Typ: %2$d) - Mich jedes Mal fragen - Vorgangs-Ausnahmen - Alle Berechtigungen widerrufen - Verbundene Apps - Ich vertraue ihr voll - Alle Anfragen automatisch signieren (außer Zahlungen) - Ich bin etwas paranoid - Nichts ohne Nachfrage signieren! - Bleiben wir vernünftig - Die häufigsten Anfragen automatisch genehmigen - Für 24 Stunden erlauben - Für 30 Tage erlauben - Bei keiner Nostr-Anfrage mehr fragen - Einmal erlauben - Nicht mehr fragen für %1$s - Für diese Sitzung erlauben - Verweigern - %1$s immer verweigern - Läuft ab - Zuletzt verwendet - Bisher haben sich keine Apps verbunden. - Alle Berechtigungen widerrufen - nApplets - nSites - Verifizierter Autor - Podcasts - Deine gespeicherten Podcasts und Episoden - von %1$s - Kapitel - Abgeschlossen - Cover hinzufügen - Quadratisches Bild, das für die Episode angezeigt wird - Lege Titel, Cover und Details deiner Show fest - Erstelle deinen Podcast - Episode bearbeiten - Dein Podcast - Audiodatei bereit zum Hochladen - Audiodatei hinzufügen - MP3, M4A oder anderes Audio - Audio-URL - https://…/episode.mp3 - Kapitel-URL - Episode löschen - Diese Episode löschen? Das kann nicht rückgängig gemacht werden. - Dauer (Sekunden) - Weitere Details - Ep %1$d - Episode # - Staffel - Zusammenfassung - Titel - Episodentitel - Themen - durch, Komma, getrennte, Tags - Transkript-URL - Video-URL - Anstößig - Hosts & Gäste - Neue Episode - Neuer Trailer - Noch keine Episoden. Tippe auf Neue Episode, um deine erste zu veröffentlichen. - Highlight abspielen - Premium - Wird veröffentlicht… - Co-Host - Redakteur - Host - Staffel %1$d - S%1$d · E%2$d - Autor - Kategorien - Technik, Nachrichten - Show abgeschlossen (keine weiteren Episoden) - Copyright - Cover hinzufügen - Quadratisches Cover für deine Show - Beschreibung - Kontakt-E-Mail - Anstößige Inhalte - Finanzierungslinks - https://… - Sprache - de - Gesperrt (Premium) - Show-Titel - Mein Podcast - Episodisch - Show-Typ - Serie - Website - Die Show unterstützen - Tippen, um Show-Details zu bearbeiten - Top-Unterstützer - Trailer - Staffel %1$d - Trailer-Titel - Trailer-Clip hinzufügen - Kurze Audio- oder Videovorschau - Medien-URL - Transkript - Ohne Titel - Adresse manuell hinzufügen - Empfänger hinzufügen - Füge Empfänger hinzu, um eingehende Sats nach Gewichtung aufzuteilen. Hörer boosten oder streamen Value an diese Ziele. - Value-for-Value-Fehler - Gebühr - Value-for-Value - Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden. - Lightning-Adresse - name@example.com - Dieser Podcast hat keine zahlbaren Value-Empfänger. - Node-Pubkey - 02abc… (33-Byte-Hex) - Name (optional) - Empfänger entfernen - Nostr-Nutzer hinzufügen - Nach Name oder @Handle suchen - %1$d%% - Sats streamen - Sendet automatisch Value, während du zuhörst. - %1$d Sats/Min - Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen. - %1$d Sats in dieser Sitzung gestreamt - Lightning-Adresse - Node (Keysend) - Dieser Nutzer hat keine Lightning-Adresse - Gewichtung - Zaps hierauf werden aufgeteilt zwischen: - Video - Dein Podcast - Zeitkritische Ereignisse (Relay-Authentifizierung, Zap-Anfragen, Wallet- und Signierer-Nachrichten, Entwürfe, Listen) werden nie gemint. - Was geschürft wird - Kommentare - Antworten auf Artikel, Dateien und andere Inhalte - Verpackungen privater Nachrichten - Ermöglicht DM-Relays das Filtern von Posteingangs-Spam; nur die äußere Verpackung wird gemint, nie deine Nachricht - Langform & Highlights - Artikel und Highlights; selten, die Kosten sind vernachlässigbar - Andere öffentliche Inhalte - Umfragen, Live-Status, Kleinanzeigen und alles andere Öffentliche - Öffentlicher & Live-Chat - Schürf-Verzögerungen stören den Gesprächsfluss - Reaktionen - Häufigster Typ; jedes Like zu schürfen kostet Akku - Meldungen - Relays gewichten Meldungen nach ihren Kosten - Reposts - Hohes Volumen bei aktiven Nutzern - Kurze Notizen & Antworten - Die primäre öffentliche Spam-Fläche - Sprachnachrichten - Öffentliche Sprachbeiträge und -antworten - Feinabstimmung des Ziels in führenden Nullbits. - Benutzerdefinierte Schwierigkeit - ≈ %1$s pro Beitrag auf diesem Gerät - Schürft vor der Veröffentlichung einen NIP-13-Arbeitsnachweis in deine Beiträge, damit Relays und Leser sie gegen Spam gewichten können. Höhere Werte dauern exponentiell länger zum Schürfen. Beiträge werden nach dem Schürfen im Hintergrund veröffentlicht. - Aus - Schwierigkeit - <1 s - Chat-Nachricht - Meldung - Arbeitsnachweis wird geschürft - Abbrechen - Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt. - Arbeitsnachweis-Schürfen - Standard (aus) - Für diesen Beitrag aus - Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden: %2$s - Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden. Es wird beim nächsten Start der App erneut versucht. - Arbeitsnachweis - ≈ %1$s verbleibend - jeden Moment - Apps & Sites - Bitcoin-(On-Chain-)Wallet - Block %1$d - Leerer Slot - PS1-Memory-Card-Spielstand - Einmal erlauben - Dieses Relay immer erlauben - Meine Nachrichten immer zustellen - Dieses Relay blockieren - Wenn nicht, wird %1$s nicht darüber benachrichtigt. - Wenn nicht, wird deine Nachricht an %1$s nicht gepostet. - Wenn nicht, siehst du hier keine Beiträge von %1$s. - Wenn nicht, siehst du %1$s hier nicht. - Wenn nicht, wird deine Nachricht an %1$s nicht zugestellt. - Wo du dich anmelden willst - Erlauben - Verweigern - Vergessen - Wann authentifiziert wird - Zuletzt verwendet vor %1$s - %1$s und andere - Hier ist noch nichts — deine globale Richtlinie gilt für jedes Relay. - Relay-spezifische Ausnahmen - Immer authentifizieren - Auth-Challenges für jedes Relay signieren, das sie anfordert - Benutzerdefiniert - Wähle genau, bei welchen Relays du dich anmeldest. Bei allem, was du unten nicht erlaubt hast, wirst du gefragt. - Nie authentifizieren - Auth-Challenges von allen Relays ignorieren - Dieses Relay möchte zuerst bestätigen, dass du es wirklich bist. Sein Betreiber sieht, welches Konto du bist. - Diesem Relay bestätigen, dass du es bist? - Mit deinem eigenen Relay verbinden - Benachrichtigen: - Dieses Relay verwenden - In diesem Raum posten - Beiträge herunterladen von: - Diesen Raum öffnen - Deine private Nachricht senden an: - auth authentifizierung relay signieren verifizieren nip-42 identität - Relay-Authentifizierung - %1$s benachrichtigen? - In %1$s posten? - Beiträge von %1$s laden? - %1$s öffnen? - Deine Nachricht an %1$s senden? - Personen, denen ich folge, Nachrichten senden - Bei den Relays einer gefolgten Person anmelden, um DMs, Antworten und Benachrichtigungen zu senden. - Jedem Nachrichten senden - Bei den Relays von Fremden anmelden, um DMs, Antworten und Benachrichtigungen zu senden. Standardmäßig aus; stattdessen wirst du jedes Mal gefragt. - Meine Relays und Räume - Bei deinen eigenen Relays und bei öffentlichen Chats, Communitys und Livestreams anmelden, denen du beigetreten bist oder die du favorisiert hast. - Beiträge von Personen lesen, denen ich folge - Bei den Relays einer gefolgten Person anmelden, um deren Beiträge herunterzuladen. - Foto hinzufügen - Nur mit Einladung - Privat - Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus. - Durchsuchen - Gib eine gültige Relay-URL ein (wss://…). - Beliebte Relays - Relay-Adresse - Gruppen finden - Relays, auf denen du bist - Foto ändern - Noch keine Gruppen auf diesem Relay. - Erstellen - Nur mit Einladung - Gruppenname - Privat (nur Mitglieder lesen) - Eine Gruppe erstellen - Thema (optional) - Rolle entfernen - Es wird nach Gruppen über deine Relays gesucht… - Noch keine Gruppen für diesen Filter gefunden. - Speichern - Nur mit Einladung - Gruppenname - Privat (nur Mitglieder lesen) - Gruppe bearbeiten - Thema (optional) - Dieses Relay favorisieren - Beschreibung - Ort (Geohash) - u0nd - Name - Gruppenbild - Themen - bitcoin, nostr, kunst - Nicht gelistet - Diese Gruppe aus dem öffentlichen Verzeichnis des Relays ausblenden. - Nur mit Einladung - Personen müssen eingeladen oder genehmigt werden, um beizutreten. - Privat - Nur Mitglieder können Nachrichten lesen. - Nur Mitglieder posten - Nur Mitglieder können Nachrichten posten. - Einladungscode (für diese Gruppe nur mit Einladung): - Einladungscode kopiert - Teile diesen Code, damit Personen dieser Gruppe beitreten können. - Einladung wird erstellt… - Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten. - Einladung wird vorbereitet… - Personen einladen - Diese Gruppe ist nur mit Einladung. Gib den Code ein, den du erhalten hast. - Einladungscode - Beitreten - Gruppe beitreten - Tritt dieser Gruppe bei, um Nachrichten zu senden. - Zum Admin machen - Zum Moderator machen - Mitglieder - Gruppe bearbeiten - Mitglieder - Noch keine Nachrichten - Gruppe öffnen - Angefragt - Dieses Relay wirbt nicht mit Unterstützung für NIP-29-Relay-Gruppen. Eine hier erstellte Gruppe funktioniert nicht — ihr Name, ihre Mitglieder und Nachrichten werden nicht vom Relay verwaltet. Wähle ein Relay, das relaybasierte Gruppen unterstützt. - Aus Gruppe entfernen - %1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden. - Admin - Mitglied - Moderator - Entdeckung - Hilf Personen, diese Gruppe zu finden. Themen und ein Ort lassen sie unter den passenden Entdeckungsfiltern erscheinen (falls das Host-Relay sie unterstützt). - Berechtigungen - Relay-Gruppen - Schreibe etwas… - Neuer Thread - Posten - Titel - Ohne Titel - Noch keine Threads. Starte einen mit der +-Schaltfläche. - Threads - Nach Relay - Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht. - Inline - Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats. - NIP-29-Gruppenanzeige - Gruppen - Relay-Gruppen - Repositorys - Deine gespeicherten Git-Repositorys - %1$s km - - %1$d Element - %1$d Elemente - - - %1$d Kapitel - %1$d Kapitel - - - %1$d Trailer - %1$d Trailer - - - %1$d Tag - %1$d Tage - - - %1$d Stunde - %1$d Stunden - - - %1$d Minute - %1$d Minuten - - - %1$d Sekunde - %1$d Sekunden - - - %1$s • schürft bei %2$d Bit - %1$s • schürft bei %2$d Bits - - - Arbeitsnachweis wird geschürft… (%1$d Beitrag in der Warteschlange) - Arbeitsnachweis wird geschürft… (%1$d Beiträge in der Warteschlange) - - - %1$d Bit - %1$d Bits - - - Standard (%1$d Bit) - Standard (%1$d Bits) - - - %1$s • wartet auf Schürfen bei %2$d Bit - %1$s • wartet auf Schürfen bei %2$d Bits - - - %1$d Person, der du folgst - %1$d Personen, denen du folgst - - - %1$d Mitglied - %1$d Mitglieder - - - %1$d Nachricht - %1$d Nachrichten - - - %1$d+ Nachricht - %1$d+ Nachrichten - diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 0ed1ec5eac..5caec58f87 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -768,6 +768,15 @@ + Utiliser ce relais + Connectez-vous à votre propre relais + Autoriser une fois + Toujours autoriser ce relais + Toujours transmettre mes messages + Bloquer ce relais + Oublier + Dernière utilisation il y a %1$s + Rien ici pour l\'instant — votre politique globale s\'applique à chaque relais. Autoriser Refuser Source : %1$s @@ -931,8 +940,12 @@ Podcasts Voir les épisodes Aucun épisode trouvé pour le moment + Saison %1$d + Explicite Terminé Premium + Soutenir la série + par %1$s Saison %1$d Vidéo Transcription @@ -984,6 +997,8 @@ Langue en Droit d\'auteur + Titre du trailer + URL du média Votre podcast Sans titre Aucun épisode pour le moment. Appuyez sur nouvel épisode pour publier votre premier épisode. @@ -1671,6 +1686,29 @@ Public Groupe Nouveau Groupe Public ou Privé + Créer + Inviter des gens + Partager ce code pour que les gens puissent rejoindre ce groupe. + Création d\'une invitation… + Code d\'invitation copié + Rejoindre le groupe + Code d\'invitation + Rejoindre + Admin + Modérateur + Membre + Membres + Privé + Sur invitation seulement + Entrer une URL de relais valide (wss://…). + Nom + Description + Photo de groupe + Ajouter une photo + Modifier la photo + Découverte + Localisation (géohash) + Privé Privé À Sujet @@ -3163,6 +3201,17 @@ Enregistre automatiquement un brouillon lorsque vous tapez ou quittez un compositeur avec du texte non envoyé, et l\'envoie à vos relais de boîte d\'envoi privée. Signature Votre signature + + %1$d heure + %1$d heures + + + %1$d jour + %1$d jours + + à tout moment maintenant + Votre %1$s a fini de miner mais n\'a pas pu être publié : %2$s + Signaler Utiliser ceci Ignorer Corriger diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 8e290818cf..a8a851778e 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -662,9 +662,9 @@ पसंदीदा में जोड़ें पसंदीदा से हटाएँ अपनी विंडो में खोलें - यह ऐप क्या एक्सेस कर सकता है - सैंडबॉक्स में चलता है, आपके खाते तक कोई विशेष पहुँच नहीं। - यह क्या एक्सेस कर सकता है + इस क्रमक के लिए क्या अभिगम्य है + पृथक पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं। + इसके लिए क्या अभिगम्य है Tor पर लोड होता है। खुले वेब पर लोड होता है। यह ऐप अभी लोड नहीं हुआ है। इसके कार्ड से खोलें, या कुछ देर बाद फिर प्रयास करें। @@ -795,9 +795,35 @@ मेरे द्वारा अनुचरित लोगों के पत्र पढें अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें उनके पत्र प्राप्त करने के लिए। मेरे द्वारा अनुचरित लोगों को सन्देश भेजें + अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए। + किसी को भी सन्देश भेजें + अज्ञात लोगों के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए। मूलविकल्पतः निष्क्रिय। प्रत्येक बार आप से पूछा जाएगा। + क्या इस पुनःप्रसारक को पुष्टि करें कि यह आप हैं। + यह पुनःप्रसारक पहले पुष्टि करना चाहता हैं कि यह वस्तुतः आप हैं। इसके परिचालक देखेगा आपका लेखा कौनसा है। + क्या आपका सन्देश %1$s को भेजें। + क्या %1$s को सूचित करें। + क्या %1$s को पत्र भेजें। + क्या %1$s को खोलें। + यदि आप पुष्टि नहीं करते तो %1$s को आपका सन्देश नहीं भेजा जाएगा। + यदि आप नहीं स्वीकारते तो %1$s को इस विषय पर सूचित नहीं किया जाएगा। + यदि आप स्वीकारते नहीं तो %1$s को आपका सन्देश प्रकाशित नहीं किया जाएगा। + यदि आप स्वीकारते नहीं तो आप यहाँ %1$s को नहीं देखेंगे। + %1$s तथा अन्य + आपका निजी सन्देश भेजें इनको : + इनको सूचित करें : + इस शाला को पत्र भेजें + इस शाला को खोलें + इस पुनःप्रसारक का उपयोग करें + अपने ही पुनःप्रसारक से संयोजन करें + एक बार अनुमत + इस पुनःप्रसारक को सर्वदा अनुमति दें + मेरे सन्देशों को सर्वदा भेजें + इस पुनःप्रसारक को बाधित करें प्रति पुनःप्रसारक आवरक आदेश + भूल जाएँ + पूर्व प्रयुक्त %1$s पहले कोई प्रति पुनःप्रसारक आवरक आदेश नहीं। वैश्विक नीति सर्वत्र लागू अनुमत अस्वीकार @@ -1746,6 +1772,110 @@ सार्वजनिक समूह नया निजी अथवा सार्वजनिक झुण्ड + पुनःप्रसारक समूह + पंक्ति में + पुनःप्रसारक अनुसार + प्रत्येक समूह को अपने ही वार्तालाप के रूप में दर्शाएँ तथा आपके चर्चाओं में मिश्रित। + प्रत्येक पुनःप्रसारक के समूह को एक ही पंक्ति मे संकुचित करें तथा उसके नवीनतम सन्देश पर रखें। + निप॰२९ समूह दृश्य + पुनःप्रसारक समूह + समूह + सन्देश + समूह बनाएँ + यह पुनःप्रसारक निप॰२९ पुनःप्रसारक समूहों का अवलम्बन करने का विज्ञापन नहीं करता। यहाँ बनाए गए समूह काम नहीं करेंगे। उसका नाम तथा सदस्यों तथा सन्देशों का प्रबन्धन पुनःप्रसारक द्वारा नहीं किया जाएगा। एक पुनःप्रसारक का चयन करें जो पुनःप्रसारक आधारित समूहों का अवलम्बन करता हो। + समूह नाम + विषय (विकल्पात्मक) + निजी (केवल सदस्य पठन) + केवल आमन्त्रित + बनाएँ + लोगों को आमन्त्रण + इस अक्षरराशि को बाँटें जिससे लोग इस समूह से जुड सकें। + आमन्त्रण बनाया जा रहा है… + आमन्त्रण अक्षरराशि अनुकृत + आमन्त्रण अक्षरराशि (इस केवल आमन्त्रित समूह के लिए): + समूह से जुडें + आमन्त्रण अक्षरराशि + यह समूह आमन्त्रण सीमित है। आपको दी गई अक्षरराशि प्रविष्ट करें। + जुडें + अनुरोधित + प्रशासक + नियामक + सदस्य + सदस्य सूची + प्रशासक बनाएँ + नियामक बनाएँ + पद हटाएँ + समूह से हटाएँ + क्या %1$s को इस समूह से हटाएँ। वे अभिगमन अधिकार खो देंगे पुनः जुडने अथवा आमन्त्रित होने तक। + समूह सम्पादन + समूह नाम + विषय (विकल्पात्मक) + निजी (केवल सदस्य पठन) + केवल आमन्त्रित + अभिलेखन + सदस्य सूची + समूह सम्पादन + सूत्र + समूह खोलें + इस पुनःप्रसारक पर कोई समूह नहीं अब तक। + आमन्त्रण उत्पादन… + निजी + केवल आमन्त्रित + प्रविष्ट करें एक मान्य पुनःप्रसारक पता (wss://…)। + नाम + विवरण + समूह चित्र + चित्र जोडें + चित्र परिवर्तन + आविष्करण + लोगों का सहयोग करें इस समूह को ढूँढने में। विषयसूची तथा स्थान से अनुकूल आविष्करण छलनियों द्वारा यह प्राप्त होगा (यदि निवास पुनःप्रसारक उनका अवलम्बन करें तो)। + विषय सूची + द्व्यंकरूप्य नोस्टर कला इत्यादि + स्थान (भूविभेदक) + u0nd + अनुमतियाँ + निजी + केवल सदस्य सन्देशों को पढ सकते हैं। + केवल आमन्त्रित + लोग आमन्त्रित अथवा अनुमत होने चाहिए जुडने के लिए। + केवल सदस्यों द्वारा पत्र प्रकाशन + केवल सदस्य सन्देश भेज सकते हैं। + सूचिबद्ध नहीं + इस समूह को छिपाएँ पुनःप्रसारक की सार्वजनिक निर्देशिका से। + आपके पुनःप्रसारकों में समूहों का खोज… + इस छलनी के अनुकूल कोई समूह प्राप्त नहीं अब तक। + इस पुनःप्रसारक को प्रियचिह्नित करें + कोई सूत्र नहीं अब तक। संकलनचिह्न घुण्डी से एक आरम्भ करें। + नया सूत्र + शीर्षकरहित + शीर्षक + कुछ लिखें… + पत्र प्रकाशन + समूह ढूँढें + पुनःप्रसारक में उपलब्ध समूहों का वीक्षण करें। पुनःप्रसारक पता चिपकाएँ अथवा नीचे चयन करें। + पुनःप्रसारक पता + वीक्षण + पुनःप्रसारक जिन पर आप हैं + लोकप्रिय पुनःप्रसारक + कोई सन्देश नहीं अब तक + इस समूह से जुडें सन्देशों को भेजने के लिए। + यह समूह आमन्त्रण सीमित है। आपके पास एक आमन्त्रण होना चाहिए पत्र प्रकाशन के लिए। + + %1$d सदस्य + %1$d सदस्य + + + %1$d सन्देश + %1$d सन्देश + + + %1$d से अधिक सन्देश + %1$d से अधिक सन्देश + + + %1$d व्यक्ति जिसके आप अनुचर + %1$d व्यक्ति जिसके आप अनुचर + निजी के लिए विषय @@ -2355,6 +2485,7 @@ क्रमक अनुशंसाएँ ज्साप प्राप्त सूचनावली अनुचर सूचनावली + द्व्यंकरूप्य (खण्डश्रृंखलाबद्ध) धनकोष प्रतिक्रिया पंक्ति समाकृति करें कि किन प्रतिक्रिया घुण्डियाँ दिखाए जाएँगे तथा उनके क्रम तथा यदि संकलन सूचक दिखाए जाएँगे। सक्षम @@ -3172,6 +3303,9 @@ %1$s सं॰ %2$d अधिक + पीएस१ स्मृति पट्टि अभिलेखन + खण्ड %1$d + रिक्त छेद रक्षापालिका गति चित्रग्राहक @@ -3303,6 +3437,14 @@ हस्ताक्षर सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। आपका हस्ताक्षर + श्रम प्रमाण + कठिनाई + एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीय अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। + निष्क्रिय + विशिष्ट कठिनाई + लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में। + क्या खनन करना चाहिए + समयसंकट घटनाओं (पुनःप्रसारक प्रमाणीकरण ज्साप अनुरोध धनकोष तथा हस्ताक्षरकर्ता सन्देश पाण्डुलिपियाँ सूचियाँ) के लिए खनन कभी नहीं। इसका प्रयोग करें हटाएँ सम्यक diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index ebdf6b3917..cf336b07d8 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1888,6 +1888,39 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Tytuł Napisz coś… Post + Znajdź grupy + Przeglądaj grupy hostowane na transmiterze. Wklej adres transmitera lub wybierz jeden z poniższych. + Adres transmitera + Wybierz + Transmitery, których używasz + Popularne transmitery + Brak wiadomości + Dołącz do tej grupy, aby wysyłać wiadomości. + Ta grupa jest dostępna wyłącznie dla zaproszonych osób — aby móc publikować posty, potrzebujesz zaproszenia. + + %1$d członek + %1$d członków + %1$d członków + %1$d członkowie + + + %1$d wiadomość + %1$d wiadomości + %1$d wiadomości + %1$d wiadomości + + + %1$d+ wiadomość + %1$d+ wiadomości + %1$d+ wiadomości + %1$d+ wiadomości + + + %1$d osoba, którą obserwujesz + %1$d osób, które obserwujesz + %1$d osób, które obserwujesz + %1$d osoby, które obserwujesz + Prywatna Do Temat @@ -3496,6 +3529,101 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Podpis Dodawane na końcu wiadomości podczas tworzenia nowego wpisu, odpowiedzi, cytatu lub artykułu. Aby wyłączyć tę funkcję, pozostaw to pole puste. Twój podpis + Dowód wykonania + Stopień trudności + Przed opublikowaniem postów generuje się w nich dowód wykonania pracy zgodny ze standardem NIP-13, dzięki czemu transmitery i czytelnicy mogą je zweryfikować pod kątem spamu. Im wyższa wartość, tym wydobycie trwa wykładniczo dłużej. Po wygenerowaniu posty są publikowane w tle. + Wył. + Indywidualny poziom trudności + Dopasuj wartość docelową pod kątem początkowych zerowych bitów. + Co wydobywać + Zdarzenia, w przypadku których czas ma kluczowe znaczenie (autoryzacja transmitera, zlecenia zap, komunikaty dotyczące portfela i sygnatariusza, wersje robocze, listy), nigdy nie są wydobywane. + Krótkie wpisy & odpowiedzi + Główne źródło spamu w sieci + Komentarze + Odpowiedzi na artykuły, pliki i inne treści + Zgłoszenia + Transmitery oceniają raporty pod kątem ich kosztów + Artykuły & główne wydarzenia + Artykuły i główne wydarzenia; pojawiają się sporadycznie, a ich koszt jest znikomy + Wiadomości głosowe + Publiczne wiadomości głosowe i odpowiedzi + Powtórzenia + Duża liczba dla aktywnych użytkowników + Reakcje + Wysoki wolumen; każde polubienie zużywa baterię + Publiczny & czat na żywo + Opóźnienia w wydobyciu zakłócają płynność rozmowy + Zawijanie wiadomości prywatnych + Pozwólmy, aby transmitery DM filtrowały spam w skrzynce odbiorczej; wydobywana jest tylko zewnętrzna otoczka, nigdy Twoja wiadomość + Inne publiczne treści + Ankiety, statusy na żywo, ogłoszenia i wszystkie inne treści publiczne + Wydobywanie dowodu wykonania pracy + + Wydobycie w systemie proof of work… (%1$d wpis w kolejce) + Wydobywanie w systemie proof of work… (%1$d wpisów w kolejce) + Wydobywanie w systemie proof of work… (%1$d wpisów w kolejce) + Dowód pracy w górnictwie… (%1$d posty w kolejce) + + + %1$s • wydobywanie z szybkością %2$d bitów + %1$s • wydobywanie z szybkością %2$d bitów + %1$s • wydobywanie z szybkością %2$d bitów + %1$s • wydobywanie z szybkością %2$d bitów + + + %1$s • oczekuje na wydobycie przy %2$d bitach + %1$s • oczekuje na wydobycie przy %2$d bitach + %1$s • oczekuje na wydobycie przy %2$d bitach + %1$s • oczekuje na wydobycie przy %2$d bitach + + + Domyślnie (%1$d bit) + Domyślnie (%1$d bitów) + Domyślnie (%1$d bitów) + Domyślne (%1$d bity) + + Domyślne (wyłączone) + Wyłączone dla tego postu + + %1$d bit + %1$d bitów + %1$d bitów + %1$d bity + + Wydobywanie dowodów wykonania pracy + Wyświetla posty, które wciąż generują dowód pracy zgodnie ze standardem NIP-13, aby proces ten mógł się zakończyć po zamknięciu aplikacji. + Anuluj + ◆ %1$s na posta na tym urządzeniu + + %1$d sekundę + %1$d sekund + %1$d sekund + %1$d sekund + + + %1$d minutę + %1$s minut + %1$d minut + %1$d minut + + + %1$d godzina + %1$d godzin + %1$d godzin + %1$d godzin + + + %1$d dzień + %1$d dni + %1$d dni + %1$d dni + + ◆ %1$s pozostało + w każdej chwili + Twój %1$s zakończył wydobycie, ale nie mógł zostać opublikowany: %2$s + Twój %1$s zakończył wydobywanie, ale nie udało się go opublikować. Próba zostanie powtórzona przy następnym uruchomieniu aplikacji. + Wiadomość Czatu + Zgłoś Użyj tego Ignoruj Popraw diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 55fe4fd8ff..b12e17d4b0 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3110,557 +3110,4 @@ Erro ao analisar JSON da busca de fatura %1$s. Verifique a configuração de iluminação do usuário Não foi possível criar uma fatura com raios. Mensagem do %1$s: %2$s Não foi possível criar uma fatura lightning a partir de %1$s: Elemento pr não encontrado no JSON resultante. - Cor de destaque - Azul - Cor principal usada em botões e links - Verde - Laranja - Rosa - Roxo - Vermelho - Categorias - Implementa - App - NIP-%1$s - Relacionados - NIPs suportados - via %1$s - Detecção de pássaros - Apps de pessoas que você segue - Sites de pessoas que você segue - Descobrir web apps - Escolher mints - Agora escolha alguns mints para hospedar seus sats. - Carteira criada - Sua carteira é salva automaticamente conforme você adiciona ou remove mints. Uma chave de nutzap é criada para você na primeira vez que adiciona um mint. - Salvando… - Configurando sua carteira… - Verificando as carteiras que encontramos… - Criar uma nova carteira - Não foi possível ler esta carteira - Mais recente — sua carteira principal - Mints: %1$s - Você tem mais de uma carteira Cashu na rede — provavelmente criadas por apps diferentes. A mais recente se torna sua carteira principal; recupere para ela os fundos das mais antigas. - Encontramos várias carteiras - Nenhum fundo recuperável - Não encontramos uma carteira Cashu existente sob sua chave Nostr em nenhum relay. Crie uma nova para começar. - Nenhuma carteira encontrada - Carteira mais antiga - Recuperar fundos para a carteira principal - %1$s sats recuperáveis - %1$s sats recuperados - Buscar novamente - Procurando sua carteira… - Procurando em todos os relays por uma carteira Cashu publicada sob sua chave Nostr. - Procurando em relays… %1$d / %2$d - Definir como carteira principal - Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas. - Esta carteira Cashu está publicada na rede Nostr sob sua chave. Use-a neste dispositivo — vamos retransmiti-la para seus relays para que seja fácil encontrá-la da próxima vez. - Encontramos sua carteira - Configure sua carteira - Usar esta carteira - Adicionado ao final da mensagem ao abrir uma nova publicação, resposta, citação ou artigo. Deixe em branco para desativar. - Sua assinatura - Assinatura - AMRAP - EMOM - Fonte - Tipo de letra usado em todo o app - Monoespaçada - Sem serifa - Serifada - Padrão do sistema - Tamanho da fonte - Ajuste o tamanho do texto em todo o app - Enorme - Grande - Normal - Pequeno - Branch - Commit - Arquivo binário não exibido. - Fechar issue - Reabrir - Base de merge - Descrição - Nova - Cancelar - Criar - Labels - bug, melhoria… - Título - Nova issue - Tentar carregar alterações novamente - Carregando alterações… - Nenhuma alteração de arquivo encontrada. - Revisado - O pull request foi atualizado para um novo commit. - Ver alterações - Arquivo binário (%1$s) — prévia não disponível. - Marcar repositório - Branches - Não foi possível carregar o repositório a partir da sua URL de clone. - Carregando repositório… - Commits - Copiar conteúdo do arquivo - padrão - Esta pasta está vazia. - Este repositório não pode ser clonado via http(s), então a visualização de código não está disponível aqui. - Hospedado externamente - Não foi possível carregar este arquivo. - Fechados & Resolvidos - Abertos - Todos - Mantido por - Este anúncio de repositório não tem URL de clone http(s) para navegar. - Nenhum histórico de commits disponível. - Nenhum arquivo correspondente. - Abrir no navegador - Texto - Este repositório não tem arquivo README. - Atividade recente - Tentar novamente - raiz - Pesquisar arquivos… - URLs de clone (uma por linha) - Descrição - Nome - Salvar - Editar repositório - Tópicos (separados por vírgula) - URLs web (uma por linha) - Branches - Arquivos - Tags - Atualizado - Código - Readme - Tags - Repositórios Git - Fechar - Marcar como merged - Reabrir - Sem título - Pull Request - Atualização de PR - - admin - Relays - %1$s… - Mensagens - Relays - Shell - Uploads - nApplet: %1$s - Bloquear e ignorar %1$s - Conectar - Como as solicitações deste app devem ser tratadas? - quer se conectar à sua conta Nostr - Conectar ao Nostr - Capacidades - Nenhum app se conectou ainda.\n\nQuando um web app se conectar à sua chave Nostr, ele aparecerá aqui. - Esquecer este app - Substituições de operação de assinatura - Nível de confiança para assinatura - apps permissões assinador napplet nsite webapp confiança conectados - Menos opções - Ocultar evento - Mais opções - Mostrar evento - quer %1$s - Permitir - Perguntar - Negar - nApplet %1$s… - ler suas mensagens privadas - criptografar uma mensagem - assinar evento kind %1$d - assinar para %1$s (kind: %2$d) - Perguntar sempre - Substituições de operação - Revogar todas as permissões - Apps conectados - Confio totalmente - Assinar automaticamente todas as solicitações (exceto pagamentos) - Sou um pouco paranoico - Não assine nada sem me perguntar! - Vamos ser razoáveis - Aprovar automaticamente as solicitações mais comuns - Permitir por 24 horas - Permitir por 30 dias - Não perguntar novamente para nenhuma solicitação Nostr - Permitir uma vez - Não perguntar novamente para %1$s - Permitir nesta sessão - Negar - Sempre negar %1$s - Expira - Usado por último - Nenhum app se conectou ainda. - Revogar todas as permissões - nApplets - nSites - Autor verificado - Podcasts - Seus podcasts e episódios marcados - por %1$s - Capítulos - Concluído - Adicionar capa - Imagem quadrada exibida para o episódio - Configure o título, a arte e os detalhes do seu programa - Crie seu podcast - Editar episódio - Seu podcast - Arquivo de áudio pronto para upload - Adicionar arquivo de áudio - MP3, M4A ou outro áudio - URL do áudio - https://…/episodio.mp3 - URL dos capítulos - Excluir episódio - Excluir este episódio? Isso não pode ser desfeito. - Duração (segundos) - Mais detalhes - Ep %1$d - Episódio nº - Temporada - Resumo - Título - Título do episódio - Tópicos - tags, separadas, por vírgula - URL da transcrição - URL do vídeo - Explícito - Apresentadores & Convidados - Novo episódio - Novo trailer - Nenhum episódio ainda. Toque em Novo episódio para publicar o primeiro. - Reproduzir destaque - Premium - Publicando… - Coapresentador - Editor - Apresentador - Temporada %1$d - T%1$d · E%2$d - Autor - Categorias - Tecnologia, Notícias - Programa concluído (sem mais episódios) - Direitos autorais - Adicionar capa - Arte quadrada para o seu programa - Descrição - E-mail de contato - Conteúdo explícito - Links de financiamento - https://… - Idioma - pt - Bloqueado (premium) - Título do programa - Meu Podcast - Episódico - Tipo de programa - Serial - Site - Apoiar o programa - Toque para editar os detalhes do programa - Principais apoiadores - Trailer - Temporada %1$d - Título do trailer - Adicionar clipe de trailer - Prévia curta de áudio ou vídeo - URL da mídia - Transcrição - Sem título - Adicionar endereço manualmente - Adicionar destinatário - Adicione destinatários para dividir os sats recebidos por peso. Os ouvintes impulsionam ou transmitem valor para esses destinos. - Erro de Value-for-Value - Taxa - Value-for-Value - Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó). - Endereço Lightning - nome@exemplo.com - Este podcast não tem destinatários de valor pagáveis. - Chave pública do nó - 02abc… (33 bytes em hex) - Nome (opcional) - Remover destinatário - Adicionar um usuário Nostr - Pesquise por nome ou @handle - %1$d%% - Transmitir sats - Envia valor automaticamente enquanto você ouve. - %1$d sats/min - Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve. - %1$d sats transmitidos nesta sessão - Endereço Lightning - Nó (keysend) - Este usuário não tem endereço Lightning - Peso - Os zaps para isto são divididos entre: - Vídeo - Seu podcast - Eventos com prazo crítico (autenticação de relay, solicitações de zap, mensagens de carteira e assinador, rascunhos, listas) nunca são minerados. - O que minerar - Comentários - Respostas a artigos, arquivos e outros conteúdos - Envelopes de mensagens privadas - Permite que os relays de DM filtrem spam da caixa de entrada; apenas o envelope externo é minerado, nunca sua mensagem - Conteúdo longo & destaques - Artigos e destaques; pouco frequentes, o custo é insignificante - Outro conteúdo público - Enquetes, status ao vivo, classificados e tudo mais que for público - Chat público & ao vivo - Atrasos de mineração prejudicam o fluxo da conversa - Reações - Kind de maior volume; minerar cada curtida gasta bateria - Denúncias - Os relays ponderam as denúncias pelo seu custo - Republicações - Alto volume para usuários ativos - Notas curtas & respostas - A principal superfície de spam público - Mensagens de voz - Publicações e respostas de voz públicas - Ajuste com precisão o alvo em bits zero à esquerda. - Dificuldade personalizada - ≈ %1$s por publicação neste dispositivo - Minera uma prova de trabalho NIP-13 nas suas publicações antes de publicar, para que relays e leitores possam ponderá-las contra spam. Valores mais altos levam exponencialmente mais tempo para minerar. As publicações são publicadas em segundo plano depois de mineradas. - Desligado - Dificuldade - <1 s - Mensagem de chat - Denúncia - Minerando prova de trabalho - Cancelar - Mostra publicações que ainda estão minerando sua prova de trabalho NIP-13 para que possam terminar depois que você sair do app. - Mineração de prova de trabalho - Padrão (desligado) - Desligado para esta publicação - Seu %1$s terminou de minerar, mas não pôde ser publicado: %2$s - Seu %1$s terminou de minerar, mas não pôde ser publicado. Uma nova tentativa será feita na próxima vez que o app iniciar. - Prova de trabalho - ≈ %1$s restante - a qualquer momento - Apps & Sites - Carteira Bitcoin (on-chain) - bloco %1$d - Espaço vazio - Salvamento em cartão de memória PS1 - Permitir uma vez - Sempre permitir este relay - Sempre entregar minhas mensagens - Bloquear este relay - Se não fizer isso, %1$s não será notificado sobre isto. - Se não fizer isso, sua mensagem para %1$s não será publicada. - Se não fizer isso, você não verá publicações de %1$s aqui. - Se não fizer isso, você não verá %1$s aqui. - Se não fizer isso, sua mensagem para %1$s não será entregue. - Em qual fazer login - Permitir - Negar - Esquecer - Quando autenticar - Usado por último há %1$s - %1$s e outros - Nada aqui ainda — sua política global se aplica a todos os relays. - Substituições por relay - Sempre autenticar - Assinar desafios de autenticação de todos os relays que solicitarem - Personalizado - Escolha exatamente em quais relays fazer login. Você será perguntado sobre qualquer um que não tenha permitido abaixo. - Nunca autenticar - Ignorar desafios de autenticação de todos os relays - Este relay quer confirmar que é realmente você primeiro. Seu operador verá qual conta você é. - Confirmar que é você para este relay? - Conectar ao seu próprio relay - Notificar: - Usar este relay - Publicar nesta sala - Baixar publicações de: - Abrir esta sala - Enviar sua mensagem privada para: - auth autenticação relay assinar verificar nip-42 identidade - Autenticação de relay - Notificar %1$s? - Publicar em %1$s? - Carregar publicações de %1$s? - Abrir %1$s? - Enviar sua mensagem para %1$s? - Enviar mensagem a pessoas que sigo - Fazer login nos relays de quem você segue para enviar DMs, respostas e notificações. - Enviar mensagem a qualquer pessoa - Fazer login nos relays de desconhecidos para enviar DMs, respostas e notificações. Desativado por padrão; em vez disso, você será perguntado a cada vez. - Meus relays e salas - Fazer login nos seus próprios relays e em chats públicos, comunidades e transmissões ao vivo que você entrou ou favoritou. - Ler publicações de pessoas que sigo - Fazer login nos relays de quem você segue para baixar as publicações deles. - Adicionar foto - Somente por convite - Privado - Explore os grupos hospedados em um relay. Cole um endereço de relay ou escolha um abaixo. - Explorar - Insira uma URL de relay válida (wss://…). - Relays populares - Endereço do relay - Encontrar grupos - Relays em que você está - Alterar foto - Nenhum grupo neste relay ainda. - Criar - Somente por convite - Nome do grupo - Privado (leitura somente para membros) - Criar um grupo - Tópico (opcional) - Remover função - Procurando grupos nos seus relays… - Nenhum grupo encontrado para este filtro ainda. - Salvar - Somente por convite - Nome do grupo - Privado (leitura somente para membros) - Editar grupo - Tópico (opcional) - Favoritar este relay - Descrição - Localização (geohash) - u0nd - Nome - Imagem do grupo - Tópicos - bitcoin, nostr, arte - Não listado - Ocultar este grupo do diretório público do relay. - Somente por convite - As pessoas precisam ser convidadas ou aprovadas para entrar. - Privado - Apenas membros podem ler as mensagens. - Publicação somente para membros - Apenas membros podem publicar mensagens. - Código de convite (para este grupo somente por convite): - Código de convite copiado - Compartilhe este código para que as pessoas possam entrar neste grupo. - Criando um convite… - Este grupo é somente por convite — você precisa de um convite para publicar. - Preparando convite… - Convidar pessoas - Este grupo é somente por convite. Insira o código que você recebeu. - Código de convite - Entrar - Entrar no grupo - Entre neste grupo para enviar mensagens. - Tornar admin - Tornar moderador - Membros - Editar grupo - Membros - Nenhuma mensagem ainda - Abrir grupo - Solicitado - Este relay não anuncia suporte a grupos NIP-29. Um grupo criado aqui não funcionará — seu nome, membros e mensagens não serão gerenciados pelo relay. Escolha um relay que suporte grupos baseados em relay. - Remover do grupo - Remover %1$s deste grupo? A pessoa perderá o acesso até ser readicionada ou reconvidada. - Admin - Membro - Moderador - Descoberta - Ajude as pessoas a encontrar este grupo. Tópicos e uma localização fazem-no aparecer nos filtros de descoberta correspondentes (se o relay host os suportar). - Permissões - Grupos de relay - Escreva algo… - Nova thread - Publicar - Título - Sem título - Nenhuma thread ainda. Comece uma com o botão +. - Threads - Por relay - Agrupa os grupos de cada relay em uma única linha, posicionada na sua mensagem mais recente. - Em linha - Mostra cada grupo como sua própria conversa, misturado com seus chats. - Exibição de grupos NIP-29 - Grupos - Grupos de relay - Repositórios - Seus repositórios git marcados - %1$s km - Volume - - %1$d arquivo alterado - %1$d arquivos alterados - - - %1$d item - %1$d itens - - - %1$d msg - %1$d msgs - - - %1$d capítulo - %1$d capítulos - - - %1$d trailer - %1$d trailers - - - %1$d dia - %1$d dias - - - %1$d hora - %1$d horas - - - %1$d minuto - %1$d minutos - - - %1$d segundo - %1$d segundos - - - %1$s • minerando em %2$d bit - %1$s • minerando em %2$d bits - - - Minerando prova de trabalho… (%1$d publicação na fila) - Minerando prova de trabalho… (%1$d publicações na fila) - - - %1$d bit - %1$d bits - - - Padrão (%1$d bit) - Padrão (%1$d bits) - - - %1$s • aguardando para minerar em %2$d bit - %1$s • aguardando para minerar em %2$d bits - - - %1$d pessoa que você segue - %1$d pessoas que você segue - - - %1$d membro - %1$d membros - - - %1$d mensagem - %1$d mensagens - - - %1$d+ mensagem - %1$d+ mensagens - diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index fd9d2a11a7..ca69e77142 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3115,552 +3115,4 @@ Fel vid tolkning av JSON från %1$ss fakturahämtning. Kontrollera användarens blixtkonfiguration Det gick inte att skapa en blixt faktura. Meddelande från %1$s: %2$s Det gick inte att skapa en blixtfaktura från %1$s: Elementet pr hittades inte i den resulterande JSON. - Accentfärg - Blå - Huvudfärg som används för knappar och länkar - Grön - Orange - Rosa - Lila - Röd - Kategorier - Implementerar - App - NIP-%1$s - Relaterat - NIP:er som stöds - via %1$s - Fågeldetektering - Appar från personer du följer - Webbplatser från personer du följer - Upptäck webbappar - Välj mints - Välj nu några mints som ska hosta dina sats. - Plånbok skapad - Din plånbok sparas automatiskt när du lägger till eller tar bort mints. En nutzap-nyckel skapas åt dig första gången du lägger till en mint. - Sparar… - Konfigurerar din plånbok… - Verifierar plånböckerna vi hittade… - Skapa en ny plånbok - Det gick inte att läsa den här plånboken - Nyast — din huvudplånbok - Mints: %1$s - Du har mer än en Cashu-plånbok i nätverket — troligen skapade av olika appar. Den nyaste blir din huvudplånbok; återhämta eventuella medel från de äldre till den. - Vi hittade flera plånböcker - Inga återvinningsbara medel - Vi kunde inte hitta någon befintlig Cashu-plånbok under din Nostr-nyckel på något relä. Skapa en ny för att komma igång. - Ingen plånbok hittades - Äldre plånbok - Återhämta medel till huvudplånboken - %1$s sats kan återvinnas - Återhämtade %1$s sats - Sök igen - Söker efter din plånbok… - Söker på alla reläer efter en Cashu-plånbok publicerad under din Nostr-nyckel. - Söker på reläer… %1$d / %2$d - Ange som huvudplånbok - Ange din huvudplånbok först, återhämta sedan medel från de äldre. - Den här Cashu-plånboken är publicerad i Nostr-nätverket under din nyckel. Använd den på den här enheten — vi sänder om den till dina reläer så att den är lätt att hitta nästa gång. - Vi hittade din plånbok - Konfigurera din plånbok - Använd den här plånboken - Läggs till i slutet av meddelandet när du öppnar ett nytt inlägg, svar, citat eller artikel. Lämna tomt för att inaktivera. - Din signatur - Signatur - AMRAP - EMOM - Typsnitt - Teckensnitt som används genom hela appen - Monospace - Sans Serif - Serif - Systemstandard - Teckenstorlek - Skala textstorleken i hela appen - Enorm - Stor - Normal - Liten - Gren - Commit - Binär fil visas inte. - Stäng ärende - Öppna igen - Mergebas - Beskrivning - Ny - Avbryt - Skapa - Etiketter - bugg, förbättring… - Titel - Nytt ärende - Försök läsa in ändringar igen - Läser in ändringar… - Inga filändringar hittades. - Reviderad - Uppdaterade pull request till en ny commit. - Visa ändringar - Binär fil (%1$s) — förhandsvisning ej tillgänglig. - Bokmärk repository - Grenar - Det gick inte att läsa in repositoryt från dess klon-URL. - Läser in repository… - Commits - Kopiera filinnehåll - standard - Den här mappen är tom. - Det här repositoryt kan inte klonas över http(s), så kodvyn är inte tillgänglig här. - Hostat externt - Det gick inte att läsa in den här filen. - Stängda & lösta - Öppna - Alla - Underhålls av - Den här repository-annonseringen har ingen http(s)-klon-URL att bläddra i. - Ingen commit-historik tillgänglig. - Inga matchande filer. - Öppna i webbläsare - Text - Det här repositoryt har ingen README-fil. - Senaste aktivitet - Försök igen - rot - Sök filer… - Klon-URL:er (en per rad) - Beskrivning - Namn - Spara - Redigera repository - Ämnen (kommaseparerade) - Webb-URL:er (en per rad) - Grenar - Filer - Taggar - Uppdaterad - Kod - Readme - Taggar - Git-repositories - Stäng - Markera som mergad - Öppna igen - Namnlös - Pull Request - PR-uppdatering - - admin - %1$s… - Meddelanden - Shell - nApplet: %1$s - Blockera och ignorera %1$s - Anslut - Hur ska den här appens förfrågningar hanteras? - vill ansluta till ditt Nostr-konto - Anslut till Nostr - Behörigheter - Inga appar har anslutit ännu.\n\nNär en webbapp ansluter till din Nostr-nyckel visas den här. - Glöm den här appen - Åsidosättningar för signeringsoperationer - Förtroendenivå för signering - appar behörigheter signerare napplet nsite webapp förtroende anslutna - Färre alternativ - Dölj händelse - Fler alternativ - Visa händelse - vill %1$s - Tillåt - Fråga - Neka - nApplet %1$s… - läsa dina privata meddelanden - kryptera ett meddelande - signera en händelse av typ %1$d - signera för %1$s (typ: %2$d) - Fråga mig varje gång - Åsidosättningar för operationer - Återkalla alla behörigheter - Anslutna appar - Jag litar helt på den - Signera automatiskt alla förfrågningar (utom betalningar) - Jag är lite paranoid - Signera inget utan att fråga mig! - Låt oss vara rimliga - Godkänn automatiskt de vanligaste förfrågningarna - Tillåt i 24 timmar - Tillåt i 30 dagar - Fråga inte igen för några Nostr-förfrågningar - Tillåt en gång - Fråga inte igen om att %1$s - Tillåt för den här sessionen - Neka - Neka alltid %1$s - Upphör - Senast använd - Inga appar har anslutit ännu. - Återkalla alla behörigheter - nApplets - nSites - Verifierad författare - Poddar - Dina bokmärkta poddar och avsnitt - av %1$s - Kapitel - Slutförd - Lägg till omslagsbild - Kvadratisk bild som visas för avsnittet - Ange din podds titel, bild och detaljer - Skapa din podd - Redigera avsnitt - Din podd - Ljudfil redo att laddas upp - Lägg till ljudfil - MP3, M4A eller annat ljud - Ljud-URL - https://…/avsnitt.mp3 - Kapitel-URL - Ta bort avsnitt - Ta bort det här avsnittet? Detta kan inte ångras. - Längd (sekunder) - Fler detaljer - Avs %1$d - Avsnitt # - Säsong - Sammanfattning - Titel - Avsnittstitel - Ämnen - komma, separerade, taggar - Transkriptions-URL - Video-URL - Explicit - Värdar & gäster - Nytt avsnitt - Ny trailer - Inga avsnitt ännu. Tryck på Nytt avsnitt för att publicera ditt första. - Spela höjdpunkt - Premium - Publicerar… - Medvärd - Redaktör - Värd - Säsong %1$d - S%1$d · A%2$d - Författare - Kategorier - Teknik, Nyheter - Podden är komplett (inga fler avsnitt) - Upphovsrätt - Lägg till omslagsbild - Kvadratisk bild för din podd - Beskrivning - Kontakt-e-post - Explicit innehåll - Finansieringslänkar - https://… - Språk - sv - Låst (premium) - Poddtitel - Min podd - Fristående avsnitt - Poddtyp - Seriell - Webbplats - Stöd podden - Tryck för att redigera podddetaljer - Främsta supportrar - Trailer - Säsong %1$d - Trailertitel - Lägg till trailerklipp - Kort ljud- eller videoförhandsvisning - Media-URL - Transkription - Namnlös - Lägg till adress manuellt - Lägg till mottagare - Lägg till mottagare för att fördela inkommande sats efter vikt. Lyssnare boostar eller streamar värde till dessa destinationer. - Value-for-Value-fel - Avgift - Value-for-Value - Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder). - Lightning-adress - namn@example.com - Den här podden har inga betalbara värdemottagare. - Nodens publika nyckel - 02abc… (33-byte hex) - Namn (valfritt) - Ta bort mottagare - Lägg till en Nostr-användare - Sök efter namn eller @handtag - %1$d%% - Streama sats - Skickar värde automatiskt medan du lyssnar. - %1$d sats/min - Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar. - Streamade %1$d sats denna session - Lightning-adress - Nod (keysend) - Den här användaren har ingen Lightning-adress - Vikt - Zaps till detta fördelas mellan: - Video - Din podd - Tidskritiska händelser (relä-autentisering, zap-förfrågningar, plånboks- och signerarmeddelanden, utkast, listor) minas aldrig. - Vad som ska minas - Kommentarer - Svar på artiklar, filer och annat innehåll - Omslag för privata meddelanden - Låter DM-reläer filtrera inkorgsspam; endast det yttre omslaget minas, aldrig ditt meddelande - Långform & höjdpunkter - Artiklar och höjdpunkter; sällsynta, kostnaden är försumbar - Annat offentligt innehåll - Omröstningar, livestatus, radannonser och allt annat offentligt - Offentlig & livechatt - Miningfördröjningar skadar konversationsflödet - Reaktioner - Den mest högvolymiga typen; att mina varje gillning kostar batteri - Rapporter - Reläer väger rapporter efter deras kostnad - Reposter - Hög volym för aktiva användare - Korta notiser & svar - Den primära offentliga spamytan - Röstmeddelanden - Offentliga röstinlägg och svar - Finjustera målet i inledande nollbitar. - Anpassad svårighetsgrad - ≈ %1$s per inlägg på den här enheten - Minar ett NIP-13-bevis på arbete i dina inlägg innan de publiceras så att reläer och läsare kan väga dem mot spam. Högre värden tar exponentiellt längre tid att mina. Inlägg publiceras i bakgrunden när de är minade. - Av - Svårighetsgrad - <1 s - Chattmeddelande - Rapport - Minar bevis på arbete - Avbryt - Visar inlägg som fortfarande minar sitt NIP-13-bevis på arbete så att de kan slutföras efter att du lämnat appen. - Mining av bevis på arbete - Standard (av) - Av för det här inlägget - Ditt %1$s slutförde miningen men kunde inte publiceras: %2$s - Ditt %1$s slutförde miningen men kunde inte publiceras. Det görs ett nytt försök nästa gång appen startar. - Bevis på arbete - ≈ %1$s kvar - när som helst nu - Bitcoin-plånbok (on-chain) - block %1$d - Tom plats - PS1-minneskortsparning - Tillåt en gång - Tillåt alltid det här reläet - Leverera alltid mina meddelanden - Blockera det här reläet - Om du inte gör det meddelas inte %1$s om detta. - Om du inte gör det publiceras inte ditt meddelande till %1$s. - Om du inte gör det ser du inte inlägg från %1$s här. - Om du inte gör det ser du inte %1$s här. - Om du inte gör det levereras inte ditt meddelande till %1$s. - Vad du ska logga in på - Tillåt - Neka - Glöm - När autentisering ska ske - Senast använd för %1$s sedan - %1$s med flera - Inget här ännu — din globala policy gäller för varje relä. - Åsidosättningar per relä - Autentisera alltid - Signera autentiseringsutmaningar för varje relä som begär det - Anpassad - Välj exakt vilka reläer du vill logga in på. Du tillfrågas om allt du inte har tillåtit nedan. - Autentisera aldrig - Ignorera autentiseringsutmaningar från alla reläer - Det här reläet vill först bekräfta att det verkligen är du. Dess operatör ser vilket konto du är. - Bekräfta att det är du för det här reläet? - Anslut till ditt eget relä - Meddela: - Använd det här reläet - Publicera till det här rummet - Ladda ner inlägg från: - Öppna det här rummet - Skicka ditt privata meddelande till: - auth autentisering relä signera verifiera nip-42 identitet - Reläautentisering - Meddela %1$s? - Publicera till %1$s? - Läs in inlägg från %1$s? - Öppna %1$s? - Skicka ditt meddelande till %1$s? - Meddela personer jag följer - Logga in på en följd persons reläer för att skicka DM, svar och notiser. - Meddela vem som helst - Logga in på främlingars reläer för att skicka DM, svar och notiser. Av som standard; du tillfrågas i stället varje gång. - Mina reläer och platser - Logga in på dina egna reläer och på offentliga chattar, communities och livestreams som du har gått med i eller favoritmarkerat. - Läs inlägg från personer jag följer - Logga in på en följd persons reläer för att ladda ner deras inlägg. - Lägg till foto - Endast inbjudan - Privat - Bläddra bland grupperna som hostas på ett relä. Klistra in en reläadress eller välj en nedan. - Bläddra - Ange en giltig relä-URL (wss://…). - Populära reläer - Reläadress - Hitta grupper - Reläer du är på - Ändra foto - Inga grupper på det här reläet ännu. - Skapa - Endast inbjudan - Gruppnamn - Privat (läsning endast för medlemmar) - Skapa en grupp - Ämne (valfritt) - Ta bort roll - Söker efter grupper på dina reläer… - Inga grupper hittades för det här filtret ännu. - Spara - Endast inbjudan - Gruppnamn - Privat (läsning endast för medlemmar) - Redigera grupp - Ämne (valfritt) - Favoritmarkera det här reläet - Beskrivning - Plats (geohash) - u0nd - Namn - Gruppbild - Ämnen - bitcoin, nostr, konst - Ej listad - Dölj den här gruppen från reläets offentliga katalog. - Endast inbjudan - Personer måste bjudas in eller godkännas för att gå med. - Privat - Endast medlemmar kan läsa meddelanden. - Publicering endast för medlemmar - Endast medlemmar kan publicera meddelanden. - Inbjudningskod (för den här gruppen med endast inbjudan): - Inbjudningskod kopierad - Dela den här koden så att personer kan gå med i gruppen. - Skapar en inbjudan… - Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera. - Förbereder inbjudan… - Bjud in personer - Den här gruppen är endast för inbjudna. Ange koden du fick. - Inbjudningskod - Gå med - Gå med i grupp - Gå med i den här gruppen för att skicka meddelanden. - Gör till admin - Gör till moderator - Medlemmar - Redigera grupp - Medlemmar - Inga meddelanden ännu - Öppna grupp - Begärd - Det här reläet annonserar inte stöd för NIP-29-relägrupper. En grupp som skapas här fungerar inte — dess namn, medlemmar och meddelanden hanteras inte av reläet. Välj ett relä som stöder reläbaserade grupper. - Ta bort från grupp - Ta bort %1$s från den här gruppen? De förlorar åtkomst tills de läggs till eller bjuds in igen. - Admin - Medlem - Moderator - Upptäckt - Hjälp personer att hitta den här gruppen. Ämnen och en plats gör att den syns under matchande upptäcktsfilter (om värdreläet stöder dem). - Behörigheter - Relägrupper - Skriv något… - Ny tråd - Publicera - Titel - Namnlös - Inga trådar ännu. Starta en med +-knappen. - Trådar - Efter relä - Slå ihop varje reläs grupper till en enda rad, placerad vid dess nyaste meddelande. - Inline - Visa varje grupp som en egen konversation, blandad med dina chattar. - Visning av NIP-29-grupper - Grupper - Relägrupper - Repositories - Dina bokmärkta git-repositories - %1$s km - - %1$d fil ändrad - %1$d filer ändrade - - - %1$d objekt - %1$d objekt - - - %1$d medd - %1$d medd - - - %1$d kapitel - %1$d kapitel - - - %1$d trailer - %1$d trailers - - - %1$d dag - %1$d dagar - - - %1$d timme - %1$d timmar - - - %1$d minut - %1$d minuter - - - %1$d sekund - %1$d sekunder - - - %1$s • minar vid %2$d bit - %1$s • minar vid %2$d bitar - - - Minar bevis på arbete… (%1$d inlägg i kön) - Minar bevis på arbete… (%1$d inlägg i kön) - - - %1$d bit - %1$d bitar - - - Standard (%1$d bit) - Standard (%1$d bitar) - - - %1$s • väntar på att minas vid %2$d bit - %1$s • väntar på att minas vid %2$d bitar - - - %1$d person du följer - %1$d personer du följer - - - %1$d medlem - %1$d medlemmar - - - %1$d meddelande - %1$d meddelanden - - - %1$d+ meddelande - %1$d+ meddelanden - diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 0f6d7d1c6e..88e2ab67dc 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -128,6 +128,12 @@ "Polish" ] }, + { + "user": "rajs19420616", + "languages": [ + "Hindi" + ] + }, { "user": "summoner001", "languages": [ @@ -140,30 +146,24 @@ "Chinese Simplified" ] }, - { - "user": "rajs19420616", - "languages": [ - "Hindi" - ] - }, { "user": "StellarStoic", "languages": [ "Slovenian" ] }, - { - "user": "crowdin.pretended462", - "languages": [ - "German" - ] - }, { "user": "anthony-robin", "languages": [ "French" ] }, + { + "user": "crowdin.pretended462", + "languages": [ + "German" + ] + }, { "user": "Bardesss", "languages": [ From 0e1a14a62d7d53dbc140e83549555bbd09ea48d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:34:50 +0000 Subject: [PATCH 064/206] fix: don't drop whole kind-0 profiles over one mistyped field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clients in the wild publish structurally wrong values in profile metadata — e.g. "nip05":{} — and the strict field serializers made JsonMapper throw, so contactMetaData() returned null and the entire profile (name, picture, about…) was discarded. Generalize the BirthdayTolerantSerializer precedent: string fields now use TolerantStringSerializer (accepts any JSON primitive, ignores objects/arrays/null) and the bot flag uses TolerantBooleanSerializer, so a single malformed field is skipped instead of being fatal. Non-JSON content (e.g. "Relay initialized") remains unrecoverable and still parses to null; a test pins that it does so without throwing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KSCGp6KgcnaepEMbxNroYe --- .../metadata/TolerantPrimitiveSerializers.kt | 118 ++++++++++++++++ .../quartz/nip01Core/metadata/UserMetadata.kt | 27 ++++ .../TolerantPrimitiveSerializersTest.kt | 132 ++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt new file mode 100644 index 0000000000..728fb8aaf3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializers.kt @@ -0,0 +1,118 @@ +/* + * 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.metadata + +import com.vitorpamplona.quartz.utils.Log +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** + * Tolerant serializer for kind-0 string fields. + * + * Some clients publish structurally wrong values for NIP-01/NIP-24 profile + * fields — seen in the wild: `"nip05":{}`. With the default serializer that + * type mismatch throws, and because [MetadataEvent.contactMetaData] turns any + * parse exception into `null`, one malformed field would discard the **entire** + * profile (name, picture, about…). + * + * This serializer accepts any JSON primitive (matching the pre-existing lenient + * behavior, where a bare number or boolean decodes into a string field) and + * treats objects, arrays, and JSON null as absent rather than failing. + * + * Same rationale as [BirthdayTolerantSerializer]. + */ +object TolerantStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantString", PrimitiveKind.STRING).nullable + + override fun deserialize(decoder: Decoder): String? { + require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } + + val element = decoder.decodeJsonElement() + return when { + element is JsonNull -> null + element is JsonPrimitive -> element.content + else -> { + // Log the JSON kind only, not the raw (untrusted, network-sourced) value. + Log.w("TolerantStringSerializer") { "Ignoring non-primitive string field (${element::class.simpleName})" } + null + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: String?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeString(value) + } + } +} + +/** + * Tolerant serializer for the kind-0 `bot` flag: accepts `true`/`false` and their + * quoted string forms; anything else (objects, arrays, other strings, JSON null) + * is treated as absent rather than failing the whole profile parse. + */ +object TolerantBooleanSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("com.vitorpamplona.quartz.nip01Core.metadata.TolerantBoolean", PrimitiveKind.BOOLEAN).nullable + + override fun deserialize(decoder: Decoder): Boolean? { + require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } + + val element = decoder.decodeJsonElement() + if (element is JsonPrimitive && element !is JsonNull) { + val parsed = element.booleanOrNull + if (parsed != null) return parsed + } + if (element !is JsonNull) { + Log.w("TolerantBooleanSerializer") { "Ignoring non-boolean bot field (${element::class.simpleName})" } + } + return null + } + + @OptIn(ExperimentalSerializationApi::class) + override fun serialize( + encoder: Encoder, + value: Boolean?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + encoder.encodeBoolean(value) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index b902f1604b..fc59e9540f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -35,31 +35,58 @@ class Birthday { var day: Int? = null } +// Every field uses a tolerant serializer: clients in the wild publish +// structurally wrong values (e.g. "nip05":{}, birthday as a string), and one +// bad field must not discard the whole profile — see TolerantStringSerializer. @Stable @Serializable class UserMetadata { + @Serializable(with = TolerantStringSerializer::class) var name: String? = null @SerialName("display_name") + @Serializable(with = TolerantStringSerializer::class) var displayName: String? = null + + @Serializable(with = TolerantStringSerializer::class) var picture: String? = null + + @Serializable(with = TolerantStringSerializer::class) var banner: String? = null + + @Serializable(with = TolerantStringSerializer::class) var website: String? = null + + @Serializable(with = TolerantStringSerializer::class) var about: String? = null + + @Serializable(with = TolerantBooleanSerializer::class) var bot: Boolean? = null + + @Serializable(with = TolerantStringSerializer::class) var pronouns: String? = null @Serializable(with = BirthdayTolerantSerializer::class) var birthday: Birthday? = null + + @Serializable(with = TolerantStringSerializer::class) var nip05: String? = null + + @Serializable(with = TolerantStringSerializer::class) var domain: String? = null + + @Serializable(with = TolerantStringSerializer::class) var lud06: String? = null + + @Serializable(with = TolerantStringSerializer::class) var lud16: String? = null /** CLINK Offers pointer (`noffer1…`) the user advertises to receive payments over Nostr. */ @SerialName("clink_offer") + @Serializable(with = TolerantStringSerializer::class) var clinkOffer: String? = null + @Serializable(with = TolerantStringSerializer::class) var twitter: String? = null fun anyName(): String? = displayName ?: name diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt new file mode 100644 index 0000000000..84ba9d9c72 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/TolerantPrimitiveSerializersTest.kt @@ -0,0 +1,132 @@ +/* + * 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.metadata + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TolerantPrimitiveSerializersTest { + private fun metaWith(content: String): MetadataEvent = + EventFactory.create( + id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content, + sig = "00".repeat(64), + ) as MetadataEvent + + /** + * Regression for profiles seen in the wild that publish `"nip05":{}` + * (an empty object where NIP-05 mandates a string). Before the tolerant + * serializer this threw and [MetadataEvent.contactMetaData] returned null, + * dropping the whole profile. + */ + @Test + fun objectNip05DoesNotDropTheProfile() { + val meta = + metaWith( + """{"name":"alice","about":"welcome to follow me","picture":"https://example.com/a.jpg","nip05":{},"lud06":null,"lud16":null}""", + ).contactMetaData() + + assertIs(meta, "profile must still parse despite the malformed nip05") + assertEquals("alice", meta.name) + assertEquals("welcome to follow me", meta.about) + assertEquals("https://example.com/a.jpg", meta.picture) + assertNull(meta.nip05, "non-string nip05 must be ignored, not fatal") + assertNull(meta.lud06) + assertNull(meta.lud16) + } + + @Test + fun otherNonStringValuesAreIgnoredFieldByField() { + val meta = + metaWith( + """{"name":{"first":"A"},"display_name":["A"],"picture":null,"about":"bio","website":{}}""", + ).contactMetaData() + + assertIs(meta) + assertNull(meta.name) + assertNull(meta.displayName) + assertNull(meta.picture) + assertNull(meta.website) + assertEquals("bio", meta.about, "well-formed fields must survive the malformed ones") + } + + /** Pre-existing lenient behavior: bare primitives still decode into string fields. */ + @Test + fun numberAndBooleanPrimitivesStillDecodeIntoStringFields() { + val meta = metaWith("""{"name":123,"about":true}""").contactMetaData() + + assertIs(meta) + assertEquals("123", meta.name) + assertEquals("true", meta.about) + } + + @Test + fun malformedBotFlagIsIgnored() { + listOf( + """{"name":"A","bot":{}}""", + """{"name":"A","bot":[true]}""", + """{"name":"A","bot":"maybe"}""", + """{"name":"A","bot":null}""", + ).forEach { json -> + val meta = metaWith(json).contactMetaData() + assertIs(meta, "profile must survive bot field in $json") + assertEquals("A", meta.name) + assertNull(meta.bot) + } + } + + @Test + fun stringBotFlagStillDecodes() { + val meta = metaWith("""{"name":"A","bot":"true"}""").contactMetaData() + assertIs(meta) + assertEquals(true, meta.bot) + } + + /** + * Content that is not JSON at all (some relays hand back things like + * "Relay initialized") cannot be salvaged: contactMetaData must return + * null without throwing. + */ + @Test + fun nonJsonContentReturnsNull() { + assertNull(metaWith("Relay initialized").contactMetaData()) + assertNull(metaWith("").contactMetaData()) + } + + /** Dropped fields must not leak back into the serialized profile as nulls. */ + @Test + fun droppedFieldsAreOmittedOnSerialization() { + val meta = metaWith("""{"name":"A","nip05":{}}""").contactMetaData() + assertIs(meta) + val serialized = JsonMapper.toJson(meta) + assertTrue("nip05" !in serialized, "a dropped nip05 should not be serialized back out: $serialized") + assertTrue("\"name\":\"A\"" in serialized, "valid fields must round-trip: $serialized") + } +} From dca345212b144a3d5d0cd367f48ea7c03fbfb2a1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Thu, 9 Jul 2026 23:35:05 +0100 Subject: [PATCH 065/206] build: aggregate Kover coverage for SonarQube import - Apply Kover (Apache-2.0, build-time only) to the KMP/JVM modules and aggregate at the root - include amethyst playDebug in the aggregated Kover coverage --- BUILDING.md | 19 +++++++++++++ amethyst/build.gradle.kts | 1 + build.gradle.kts | 53 ++++++++++++++++++++++++++++++++++++ cli/build.gradle.kts | 1 + commons/build.gradle.kts | 1 + desktopApp/build.gradle.kts | 1 + geode/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ nestsClient/build.gradle.kts | 1 + quartz/build.gradle.kts | 1 + quic/build.gradle.kts | 1 + relayBench/build.gradle.kts | 1 + 12 files changed, 83 insertions(+) diff --git a/BUILDING.md b/BUILDING.md index 54b6021e97..06dec12b15 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -278,6 +278,25 @@ call does not guarantee lint finishes first): The reports persist under each module's `build/reports/`, so re-run lint only when you want fresh lint data in the next scan. +### 5. Optional: include test coverage + +Coverage comes from [Kover](https://github.com/Kotlin/kotlinx-kover). The root +`koverXmlReportCoverage` task runs the unit-test suites of the covered modules +(`quartz`, `commons`, `quic`, `nestsClient`, `geode`, `cli`, `relayBench`, +`desktopApp`, and `amethyst`'s `fdroidDebug` variant) and merges the result +into `build/reports/kover/reportCoverage.xml`, which the scanner imports +automatically when it exists. Same recipe as lint — generate first, scan +second: + +```bash +./gradlew koverXmlReportCoverage +./gradlew sonar +``` + +For a human-readable version of the same data, run +`./gradlew koverHtmlReportCoverage` and open +`build/reports/kover/htmlCoverage/index.html`. + Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any [analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) can be set there. `sonar.projectKey` / `sonar.projectName` default to the root diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index e4df9eb8bf..d7d65cc0cb 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) + alias(libs.plugins.kotlinxKover) } fun getCurrentBranch(workingDir: java.io.File): String = diff --git a/build.gradle.kts b/build.gradle.kts index 6b87fbd049..5d6326492d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,6 +45,50 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) apply false + alias(libs.plugins.kotlinxKover) +} + +// Aggregated code coverage: `./gradlew koverXmlReportCoverage` runs the unit +// test suites of the modules below and merges their coverage into +// build/reports/kover/reportCoverage.xml (JaCoCo-compatible XML, importable by +// SonarQube — see BUILDING.md). `koverHtmlReportCoverage` renders the same +// data for humans. A named variant is used instead of Kover's default total +// report because the total for an Android project unconditionally merges +// EVERY build variant — compiling all release/benchmark variants of +// :amethyst just to measure unit tests. The "coverage" variant below picks +// one representative compilation per project: the `jvm` target for KMP/JVM +// modules and `playDebug` for :amethyst — play is the primary flavor and the +// larger code surface (both `add`s are optional so the one block fits every +// project). :nappletHost stays out — it has no tests. +dependencies { + kover(project(":amethyst")) + kover(project(":quartz")) + kover(project(":commons")) + kover(project(":quic")) + kover(project(":nestsClient")) + kover(project(":geode")) + kover(project(":cli")) + kover(project(":relayBench")) + kover(project(":desktopApp")) +} + +kover { + currentProject { + createVariant("coverage") {} + } +} + +subprojects { + plugins.withId("org.jetbrains.kotlinx.kover") { + configure { + currentProject { + createVariant("coverage") { + add("jvm", optional = true) + add("playDebug", optional = true) + } + } + } + } } // Shared app version for all subprojects — read from gradle/libs.versions.toml. @@ -120,6 +164,15 @@ if (sonarEnabled) { .filter { it.startsWith("sonar.") } .forEach { System.setProperty(it, sonarProperties.getProperty(it)) } + // Import Kover's aggregated coverage (run `./gradlew koverXmlReportCoverage` + // first) unless the operator pointed the scanner somewhere else. + if (System.getProperty("sonar.coverage.jacoco.xmlReportPaths") == null) { + System.setProperty( + "sonar.coverage.jacoco.xmlReportPaths", + layout.buildDirectory.file("reports/kover/reportCoverage.xml").get().asFile.absolutePath, + ) + } + // The scanner's sonarResolver task reads AGP's generated-res-values provider // but doesn't depend on the task that produces it — wire it up in every // module that has both (today only :amethyst enables resValues, but the diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 489e604740..7d26b235b1 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application + alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 8182a3604b..b71358533c 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -25,6 +25,7 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.serialization) + alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 10ca2b3d48..f49cad7ba2 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.kotlinxKover) } // RPM rejects dashes in version strings — replace with tilde (~) which RPM uses diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index ee636965c7..46e9f1cf23 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.serialization) application `java-test-fixtures` + alias(libs.plugins.kotlinxKover) } application { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d768731a6..cc1cf2bad1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.4.0" +kover = "0.9.8" kotlinxCollectionsImmutable = "0.5.0" kotlinxCoroutinesCore = "1.11.0" kotlinxSerialization = "1.11.0" @@ -246,6 +247,7 @@ jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlin' } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinxKover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index 3cb05ae465..bb5acd43a2 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) + alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 10c0817ee8..108587341f 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) + alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts index def6dad697..55c28f88d1 100644 --- a/quic/build.gradle.kts +++ b/quic/build.gradle.kts @@ -23,6 +23,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) + alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/relayBench/build.gradle.kts b/relayBench/build.gradle.kts index 8a02cc1e96..831f524883 100644 --- a/relayBench/build.gradle.kts +++ b/relayBench/build.gradle.kts @@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application + alias(libs.plugins.kotlinxKover) } application { From ae6983a7a82b10422d83a32e5f17b1b06c97ce48 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 14:56:26 +0000 Subject: [PATCH 066/206] 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 067/206] 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 068/206] 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 757f348a46d864c63d59e6ff049ace0cd46917d5 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:04:41 +0000 Subject: [PATCH 069/206] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 579 ++++++++++++++++++ .../src/main/res/values-de-rDE/strings.xml | 476 ++++++++++++++ .../src/main/res/values-pt-rBR/strings.xml | 493 +++++++++++++++ .../src/main/res/values-sv-rSE/strings.xml | 500 +++++++++++++++ docs/changelog/translators.json | 10 +- 5 files changed, 2054 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f826000683..f9a50c85e7 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -599,6 +599,13 @@ Zpracovává od Dostupné na + Kategorie + Související + Implementuje + Podporované NIP + prostřednictvím %1$s + NIP-%1$s + Aplikace Web Android iOS @@ -629,6 +636,7 @@ Sdílejte své tréninky Nechte Amethyst číst dokončené tréninky z Health Connect (Samsung Health, Google Fit, Fitbit, Garmin…) a navrhnout příspěvek. Připojit + %1$s km Z Health Connect Běh Chůze @@ -642,8 +650,12 @@ Dieta Půst Okruh + EMOM + AMRAP Aplikace Aplikace + nApplety + nSites Zatím nenalezeny žádné nSites. Prohlížeč Obnovit @@ -654,6 +666,9 @@ Otevřít Vymazat Oblíbené + Objevujte webové aplikace + Weby od lidí, které sledujete + Aplikace od lidí, které sledujete Možnosti Odebrat z historie Oblíbené aplikace @@ -679,11 +694,15 @@ Zapomenout tento nApplet Zablokováno Odebrat + Zeptat se pokaždé Zatím nenalezeny žádné nApplets. + nApplet %1$s… + Shell Identita Akce klávesnice + Relaye Úložiště Platby Síť @@ -727,14 +746,106 @@ Tento nApplet chce zaplatit Lightning fakturu za %1$d sats. + Připojit k Nostr + se chce připojit k vašemu účtu Nostr + Jak se mají zpracovávat požadavky této aplikace? + Připojit + Zablokovat a ignorovat %1$s + Plně jí důvěřuji + Automaticky podepisovat všechny požadavky (kromě plateb) + Buďme rozumní + Automaticky schvalovat nejběžnější požadavky + Jsem trochu paranoidní + Nepodepisuj nic, aniž by ses mě zeptal! + chce %1$s + Zobrazit událost + Skrýt událost + Více možností + Méně možností + Povolit jednou + Povolit pro tuto relaci + Povolit na 24 hodin + Povolit na 30 dní + Znovu se neptat na %1$s + Znovu se neptat na žádné požadavky Nostr + Odmítnout + Vždy odmítnout %1$s + Naposledy použito + Vyprší + podepsat událost druhu %1$d + podepsat pro %1$s (druh: %2$d) + zašifrovat zprávu + číst vaše soukromé zprávy + Připojené aplikace + Odvolat všechna oprávnění + Výjimky operací + Zatím se nepřipojily žádné aplikace. + Odvolat všechna oprávnění + Povolit + Zeptat se + Odmítnout + aplikace oprávnění podepisovač napplet nsite webová aplikace důvěra připojené + Úroveň důvěry pro podepisování + Oprávnění + Zapomenout tuto aplikaci + Výjimky pro podepisovací operace + Zatím se nepřipojily žádné aplikace.\n\nAž se webová aplikace připojí k vašemu klíči Nostr, objeví se zde. + Autentizace relay + auth autentizace relay podepsat ověřit nip-42 identita + Kdy se ověřovat + Kam se přihlásit + Vždy se ověřovat + Podepisovat autentizační výzvy pro každý relay, který o to požádá + Nikdy se neověřovat + Ignorovat autentizační výzvy ze všech relayů + Vlastní + Vyberte přesně, ke kterým relayům se přihlásit. Na cokoli, co jste níže nepovolili, budete dotázáni. + Mé relaye a místnosti + Přihlásit se ke svým vlastním relayům a k veřejným chatům, komunitám a živým vysíláním, ke kterým jste se připojili nebo je oblíbili. + Číst příspěvky od lidí, které sleduji + Přihlásit se k relayům sledovaného pro stažení jeho příspěvků. + Zprávy lidem, které sleduji + Přihlásit se k relayům sledovaného pro odesílání DM, odpovědí a upozornění. + Zprávy komukoli + Přihlásit se k relayům cizích lidí pro odesílání DM, odpovědí a upozornění. Ve výchozím nastavení vypnuto; místo toho budete dotázáni pokaždé. + Potvrdit tomuto relay, že jste to vy? + Tento relay si nejprve chce ověřit, že jste to opravdu vy. Jeho provozovatel uvidí, který účet jste. + Odeslat vaši zprávu pro %1$s? + Upozornit %1$s? + Načíst příspěvky z %1$s? + Přispět do %1$s? + Otevřít %1$s? + Pokud tak neučiníte, vaše zpráva pro %1$s nebude doručena. + Pokud tak neučiníte, %1$s o tom nebude upozorněn. + Pokud tak neučiníte, neuvidíte zde příspěvky od %1$s. + Pokud tak neučiníte, vaše zpráva na %1$s nebude odeslána. + Pokud tak neučiníte, neuvidíte zde %1$s. + %1$s a další + Odeslat vaši soukromou zprávu pro: + Upozornit: + Stáhnout příspěvky z: + Přispět do této místnosti + Otevřít tuto místnost + Použít tento relay + Připojit se ke svému vlastnímu relay + Povolit jednou + Vždy povolit tento relay + Vždy doručovat mé zprávy + Zablokovat tento relay + Výjimky pro jednotlivé relaye + Zapomenout + Naposledy použito před %1$s + Zatím zde nic není — na každý relay se vztahuje vaše globální zásada. + Povolit + Odmítnout Zdroj: %1$s v%1$s Stáhnout @@ -901,6 +1012,122 @@ Podcasty Zobrazit epizody Zatím nenalezeny žádné epizody + Upoutávka + Série %1$d + Explicitní + Dokončeno + Prémiový + Podpořte pořad + od %1$s + S%1$d · E%2$d + Ep %1$d + Série %1$d + Video + Přepis + Kapitoly + Value-for-Value + %1$d%% + Zapy na toto se rozdělují mezi: + Moderátoři a hosté + Přehrát ukázku + Nejlepší podporovatelé + + %1$d kapitola + %1$d kapitoly + %1$d kapitoly + %1$d kapitol + + Moderátor + Spolumoderátor + Editor + Ověřený autor + Chyba Value-for-Value + Tento podcast nemá žádné platitelné příjemce hodnoty. + Připojte peněženku Nostr Wallet Connect pro odesílání příjemcům keysend (uzel). + Streamovat sats + %1$d sats/min + Automaticky posílá hodnotu, když posloucháte. + V této relaci odesláno %1$d sats + Připojte peněženku Nostr Wallet Connect nebo debetní peněženku pro streamování sats během poslechu. + Nová epizoda + Upravit epizodu + Publikování… + Přidat obal + Čtvercový obrázek zobrazený u epizody + Název + Název epizody + Shrnutí + Délka (sekundy) + Více podrobností + Série + Epizoda č. + URL videa + URL přepisu + URL kapitol + Témata + čárkami, oddělené, štítky + URL zvuku + https://…/epizoda.mp3 + Zvukový soubor připraven k nahrání + Přidat zvukový soubor + MP3, M4A nebo jiný zvuk + Smazat epizodu + Smazat tuto epizodu? Tuto akci nelze vrátit zpět. + Váš podcast + Přidat obal + Čtvercový obal vašeho pořadu + Název pořadu + Můj podcast + Popis + Autor + Kontaktní e-mail + Web + Kategorie + Technologie, Zprávy + Odkazy na financování + https://… + Jazyk + cs + Autorská práva + Typ pořadu + Epizodický + Seriálový + Explicitní obsah + Pořad kompletní (žádné další epizody) + Uzamčeno (prémiové) + Nová upoutávka + Přidat klip upoutávky + Krátký zvukový nebo video náhled + Název upoutávky + URL média + Váš podcast + Bez názvu + Zatím žádné epizody. Klepnutím na Nová epizoda publikujte svou první. + Vytvořte svůj podcast + Klepnutím upravíte podrobnosti pořadu + Nastavte název, obal a podrobnosti svého pořadu + + %1$d upoutávka + %1$d upoutávky + %1$d upoutávky + %1$d upoutávek + + Přidejte příjemce pro rozdělení příchozích sats podle váhy. Posluchači posílají boost nebo streamují hodnotu do těchto cílů. + Přidat příjemce + Přidat adresu ručně + Přidat uživatele Nostr + Hledat podle jména nebo @handle + Tento uživatel nemá Lightning adresu + Odebrat příjemce + Jméno (volitelné) + Lightning adresa + Uzel (keysend) + Lightning adresa + jmeno@example.com + Veřejný klíč uzlu + 02abc… (33bajtový hex) + Váha + Poplatek %1$d epizoda %1$d epizody @@ -933,6 +1160,10 @@ Soukromé záložky Veřejné záložky + Repozitáře + Vaše git repozitáře v záložkách + Podcasty + Vaše podcasty a epizody v záložkách Přidat do soukromých záložek Přidat do veřejných záložek Odebrat ze soukromých záložek @@ -1482,6 +1713,26 @@ Připojení peněženky Jazyk Motiv + Barva zvýraznění + Hlavní barva používaná napříč tlačítky a odkazy + Fialová + Modrá + Zelená + Oranžová + Červená + Růžová + Písmo + Typ písma používaný v celé aplikaci + Výchozí systémové + Bezpatkové + Patkové + Neproporcionální + Velikost písma + Změna velikosti textu v celé aplikaci + Malá + Normální + Velká + Obrovská Automaticky načítat obrázky/gif Automaticky přehrávat videa Automatické přehrávání videí @@ -1558,6 +1809,118 @@ Veřejné Skupina Nová veřejná nebo soukromá skupina + Skupiny relay + V řadě + Podle relay + Zobrazí každou skupinu jako vlastní konverzaci, promíchanou s vašimi chaty. + Sbalí skupiny každého relay do jednoho řádku, umístěného u jeho nejnovější zprávy. + Zobrazení skupin NIP-29 + Skupiny relay + Skupiny + Zprávy + Vytvořit skupinu + Tento relay neuvádí podporu skupin NIP-29. Skupina zde vytvořená nebude fungovat — její název, členové a zprávy nebudou relayem spravovány. Vyberte relay, který podporuje skupiny založené na relay. + Název skupiny + Téma (volitelné) + Soukromá (čtení jen pro členy) + Pouze na pozvání + Vytvořit + Pozvat lidi + Sdílejte tento kód, aby se lidé mohli připojit k této skupině. + Vytváření pozvánky… + Kód pozvánky zkopírován + Kód pozvánky (pro tuto skupinu na pozvání): + Připojit se ke skupině + Kód pozvánky + Tato skupina je pouze na pozvání. Zadejte kód, který jste dostali. + Připojit se + Požádáno + Správce + Moderátor + Člen + Členové + Udělat správcem + Udělat moderátorem + Odebrat roli + Odebrat ze skupiny + Odebrat %1$s z této skupiny? Ztratí přístup, dokud nebude znovu přidán nebo pozván. + Upravit skupinu + Název skupiny + Téma (volitelné) + Soukromá (čtení jen pro členy) + Pouze na pozvání + Uložit + Členové + Upravit skupinu + Vlákna + Otevřít skupinu + Na tomto relay zatím nejsou žádné skupiny. + Příprava pozvánky… + Soukromá + Pouze na pozvání + Zadejte platnou URL relay (wss://…). + Název + Popis + Obrázek skupiny + Přidat fotku + Změnit fotku + Objevování + Pomozte lidem najít tuto skupinu. Témata a poloha ji zobrazí pod odpovídajícími filtry objevování (pokud je hostitelský relay podporuje). + Témata + bitcoin, nostr, umění + Poloha (geohash) + u0nd + Oprávnění + Soukromá + Zprávy mohou číst pouze členové. + Pouze na pozvání + Lidé musí být pozváni nebo schváleni, aby se mohli připojit. + Přispívání jen pro členy + Zprávy mohou psát pouze členové. + Neuvedená + Skrýt tuto skupinu z veřejného adresáře relay. + Hledání skupin napříč vašimi relayi… + Pro tento filtr zatím nebyly nalezeny žádné skupiny. + Oblíbit tento relay + Zatím žádná vlákna. Založte jedno tlačítkem +. + Nové vlákno + Bez názvu + Název + Napište něco… + Přispět + Najít skupiny + Procházejte skupiny hostované na relay. Vložte adresu relay nebo vyberte některou níže. + Adresa relay + Procházet + Relaye, na kterých jste + Oblíbené relaye + Zatím žádné zprávy + Připojte se k této skupině pro odesílání zpráv. + Tato skupina je pouze na pozvání — k přispívání potřebujete pozvánku. + + %1$d člen + %1$d členové + %1$d člena + %1$d členů + + + %1$d zpráva + %1$d zprávy + %1$d zprávy + %1$d zpráv + + + %1$d+ zpráva + %1$d+ zprávy + %1$d+ zprávy + %1$d+ zpráv + + + %1$d osoba, kterou sledujete + %1$d osoby, které sledujete + %1$d osoby, které sledujete + %1$d osob, které sledujete + Soukromé Pro Předmět @@ -1797,12 +2160,42 @@ Vytvořte si peněženku Cashu pro uchovávání tokenů ecash a příjem nutzapů. Hledám vaši peněženku… Peněženky NIP-60 se synchronizují mezi klienty. Pokud jste si nějakou vytvořili v jiné aplikaci pod tímto klíčem Nostr, měla by se za pár sekund objevit. + Nastavte si peněženku + Hledání vaší peněženky… + Prohledáváme každý relay a hledáme peněženku Cashu publikovanou pod vaším klíčem Nostr. + Prohledávání relayů… %1$d / %2$d + Ověřování peněženek, které jsme našli… + Žádná peněženka nenalezena + Na žádném relay jsme nenašli existující peněženku Cashu pod vaším klíčem Nostr. Začněte vytvořením nové. + Vytvořit novou peněženku + Našli jsme vaši peněženku + Tato peněženka Cashu je publikována v síti Nostr pod vaším klíčem. Používejte ji na tomto zařízení — znovu ji rozešleme na vaše relaye, aby se příště snadno našla. + Použít tuto peněženku + Našli jsme několik peněženek + Máte v síti více než jednu peněženku Cashu — nejspíše vytvořené různými aplikacemi. Nejnovější se stane vaší hlavní peněženkou; převeďte do ní veškeré prostředky ze starších. + Nejnovější — vaše hlavní peněženka + Starší peněženka + Nastavit jako hlavní peněženku + Obnovit prostředky do hlavní peněženky + %1$s sats k obnovení + Obnoveno %1$s sats + Žádné obnovitelné prostředky + Tuto peněženku nelze přečíst + Nejprve nastavte hlavní peněženku, poté obnovte prostředky ze starších. + Nastavování vaší peněženky… + Hledat znovu + Mincovny: %1$s + Peněženka vytvořena + Nyní vyberte několik mincoven, které budou hostit vaše sats. + Vyberte mincovny Zůstatek Minty URL mintu Odebrat mint Přidat mint Historie + Vaše peněženka se ukládá automaticky, jak přidáváte nebo odebíráte mincovny. Klíč nutzap se pro vás vytvoří při prvním přidání mincovny. + Ukládání… Klíč pro nutzap (pokročilé) Samostatný soukromý klíč používaný pouze k příjmu nutzapů NIP-61. Není to klíč vaší identity Nostr. Vygenerovat nový klíč @@ -2158,6 +2551,7 @@ Doporučení aplikací Kanál přijatých zapů Kanál sledujících + Bitcoin (on-chain) peněženka Nastavení reakcí Nastavte, která tlačítka reakcí se zobrazují, jejich pořadí a zda se mají zobrazovat počty. Povoleno @@ -2306,6 +2700,10 @@ Rozštěpováno od Internet: Klon: + Větev + Commit + Základ sloučení + Pull request byl aktualizován na nový commit. Otevřeno Sloučeno Uzavřeno @@ -2313,12 +2711,86 @@ Přehled Úkoly Patche a PR + Otevřené + Uzavřené a vyřešené + Vše + Přidat repozitář do záložek + Větve + Značky + Soubory + Aktualizováno + Nedávná aktivita + Spravuje + Hostováno externě + Tento repozitář nelze klonovat přes http(s), takže zde není zobrazení kódu k dispozici. + Otevřít v prohlížeči + Bez názvu O projektu Odkazy Správci Témata Osobní fork + Readme + Kód + Načítání repozitáře… + Repozitář se nepodařilo načíst z jeho klonovací URL. + Toto oznámení repozitáře neobsahuje žádnou http(s) klonovací URL k procházení. + Tento repozitář nemá soubor README. + Tento soubor se nepodařilo načíst. + Binární soubor (%1$s) — náhled není k dispozici. + Tato složka je prázdná. + kořen + Zkusit znovu + výchozí + Větve + Značky + Hledat soubory… + Žádné odpovídající soubory. + Commity + Není k dispozici žádná historie commitů. + Kopírovat obsah souboru + Text + + %1$d položka + %1$d položky + %1$d položky + %1$d položek + + + %1$d soubor změněn + %1$d soubory změněny + %1$d souboru změněno + %1$d souborů změněno + + Binární soubor se nezobrazuje. + Nový + Nový problém + Název + Popis + Vytvořit + Zrušit + Štítky + bug, vylepšení… + Uzavřít problém + Znovu otevřít + Uzavřít + Znovu otevřít + Označit jako sloučené + Zobrazit změny + Načítání změn… + Nebyly nalezeny žádné změny souborů. + Zkusit načíst změny znovu + Upraveno + Upravit repozitář + Název + Popis + Klonovací URL (jedna na řádek) + Webové URL (jedna na řádek) + Témata (oddělená čárkami) + Uložit + Git repozitáře Statický web: %1$s + nApplet: %1$s Oprávnění: Aplikace & Weby Kořenový web @@ -2607,6 +3079,8 @@ Git záplata Git repozitář Git odpověď + Pull request + Aktualizace PR Cíle zapů Sledované hashtagy Zvýraznění @@ -2907,6 +3381,7 @@ %1$s financováno z %2$s sats cíle Končí %1$s On-chain dar + Detekce ptáků Birdex · %1$d druh Birdex · %1$d druhy @@ -2920,6 +3395,9 @@ %1$s +%2$d dalších + Uložení na paměťovou kartu PS1 + blok %1$d + Prázdný slot Policie Rychlostní radar @@ -3048,6 +3526,105 @@ Nastavení editoru Automaticky vytvářet koncepty Automaticky uloží koncept události, když píšeš nebo opouštíš editor s neodeslaným textem, a odešle ho na tvé soukromé odchozí relaye. + Podpis + Přidá se na konec zprávy při otevírání nového příspěvku, odpovědi, citace nebo článku. Ponechte prázdné pro vypnutí. + Váš podpis + Proof of Work + Obtížnost + Vytěží do vašich příspěvků NIP-13 proof of work před publikováním, aby je relaye a čtenáři mohli zvážit oproti spamu. Vyšší hodnoty trvají exponenciálně déle. Příspěvky se po vytěžení publikují na pozadí. + Vypnuto + Vlastní obtížnost + Jemné doladění cíle v počtu úvodních nulových bitů. + Co těžit + Časově kritické události (autentizace relay, žádosti o zap, zprávy peněženky a podepisovače, koncepty, seznamy) se nikdy netěží. + Krátké poznámky a odpovědi + Hlavní veřejná plocha pro spam + Komentáře + Odpovědi na články, soubory a jiný obsah + Nahlášení + Relaye váží nahlášení podle jejich nákladů + Dlouhé texty a zvýraznění + Články a zvýraznění; zřídkavé, náklady jsou zanedbatelné + Hlasové zprávy + Veřejné hlasové příspěvky a odpovědi + Přeposlání + Vysoký objem pro aktivní uživatele + Reakce + Nejobjemnější druh; těžení každého lajku stojí baterii + Veřejný a živý chat + Prodlevy při těžení narušují plynulost konverzace + Obálky soukromých zpráv + Umožňuje DM relayům filtrovat spam ve schránce; těží se pouze vnější obálka, nikdy vaše zpráva + Jiný veřejný obsah + Ankety, živé statusy, inzeráty a vše ostatní veřejné + Těžení proof of work + + Těžení proof of work… (%1$d příspěvek ve frontě) + Těžení proof of work… (%1$d příspěvky ve frontě) + Těžení proof of work… (%1$d příspěvku ve frontě) + Těžení proof of work… (%1$d příspěvků ve frontě) + + + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + %1$s • těžení na %2$d bitu + %1$s • těžení na %2$d bitech + + + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + %1$s • čeká na těžbu na %2$d bitu + %1$s • čeká na těžbu na %2$d bitech + + + Výchozí (%1$d bit) + Výchozí (%1$d bity) + Výchozí (%1$d bitu) + Výchozí (%1$d bitů) + + Výchozí (vypnuto) + Vypnuto pro tento příspěvek + + %1$d bit + %1$d bity + %1$d bitu + %1$d bitů + + Těžení proof of work + Zobrazuje příspěvky, které stále těží svůj NIP-13 proof of work, aby mohly dokončit těžbu i po opuštění aplikace. + Zrušit + ≈ %1$s na příspěvek na tomto zařízení + <1 s + + %1$d sekunda + %1$d sekundy + %1$d sekundy + %1$d sekund + + + %1$d minuta + %1$d minuty + %1$d minuty + %1$d minut + + + %1$d hodina + %1$d hodiny + %1$d hodiny + %1$d hodin + + + %1$d den + %1$d dny + %1$d dne + %1$d dní + + ≈ %1$s zbývá + každou chvíli + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat: %2$s + Váš %1$s dokončil těžbu, ale nepodařilo se jej publikovat. Bude to zkuseno znovu při příštím spuštění aplikace. + Chatová zpráva + Nahlášení Použít toto Zavřít Opravit @@ -3121,6 +3698,7 @@ Bude vytvořena nová MLS skupina. Členy můžete přidat poté. Skupina Marmot Skupina %1$s… + %1$s… Známé Známé (%1$d) Nové žádosti @@ -3185,6 +3763,7 @@ Odebrat administrátorská práva \"%1$s\"? Zůstane členem skupiny. Administrátorská práva odebrána Nepodařilo se odebrat administrátorská práva: %1$s + Relaye zatím žádné eventy poslední event%1$s Nejste členem této skupiny diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 1f23f55b23..090be920d0 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -586,6 +586,10 @@ von Verfügbar auf Kategorien + Verwandt + Implementiert + Unterstützte NIPs + über %1$s Web Android iOS @@ -628,6 +632,7 @@ Meditation Diät Fasten + Zirkel Apps Apps Noch keine nSites gefunden. @@ -640,6 +645,9 @@ Öffnen Löschen Favoriten + Web-Apps entdecken + Sites von Personen, denen du folgst + Apps von Personen, denen du folgst Optionen Aus Verlauf entfernen Lieblings-Apps @@ -665,6 +673,7 @@ Dieses nApplet vergessen Blockiert Widerrufen + Mich jedes Mal fragen Noch keine nApplets gefunden. @@ -711,14 +720,106 @@ Dieses nApplet möchte eine Lightning-Rechnung über %1$d sats bezahlen. + Mit Nostr verbinden + möchte sich mit deinem Nostr-Konto verbinden + Wie sollen die Anfragen dieser App behandelt werden? + Verbinden + %1$s blockieren und ignorieren + Ich vertraue ihr voll + Alle Anfragen automatisch signieren (außer Zahlungen) + Bleiben wir vernünftig + Die häufigsten Anfragen automatisch genehmigen + Ich bin etwas paranoid + Nichts ohne Nachfrage signieren! + möchte %1$s + Ereignis anzeigen + Ereignis ausblenden + Mehr Optionen + Weniger Optionen + Einmal erlauben + Für diese Sitzung erlauben + Für 24 Stunden erlauben + Für 30 Tage erlauben + Nicht mehr fragen für %1$s + Bei keiner Nostr-Anfrage mehr fragen + Verweigern + %1$s immer verweigern + Zuletzt verwendet + Läuft ab + Ereignis vom Typ %1$d signieren + für %1$s signieren (Typ: %2$d) + eine Nachricht verschlüsseln + deine privaten Nachrichten lesen + Verbundene Apps + Alle Berechtigungen widerrufen + Vorgangs-Ausnahmen + Bisher haben sich keine Apps verbunden. + Alle Berechtigungen widerrufen + Erlauben + Fragen + Verweigern + apps berechtigungen signierer napplet nsite webapp vertrauen verbunden + Signierungs-Vertrauensstufe + Berechtigungen + Diese App vergessen + Signierungs-Ausnahmen + Bisher haben sich keine Apps verbunden.\n\nWenn sich eine Web-App mit deinem Nostr-Schlüssel verbindet, erscheint sie hier. + Relay-Authentifizierung + auth authentifizierung relay signieren verifizieren nip-42 identität + Wann authentifiziert wird + Wo du dich anmelden willst + Immer authentifizieren + Auth-Challenges für jedes Relay signieren, das sie anfordert + Nie authentifizieren + Auth-Challenges von allen Relays ignorieren + Benutzerdefiniert + Wähle genau, bei welchen Relays du dich anmeldest. Bei allem, was du unten nicht erlaubt hast, wirst du gefragt. + Meine Relays und Räume + Bei deinen eigenen Relays und bei öffentlichen Chats, Communitys und Livestreams anmelden, denen du beigetreten bist oder die du favorisiert hast. + Beiträge von Personen lesen, denen ich folge + Bei den Relays einer gefolgten Person anmelden, um deren Beiträge herunterzuladen. + Personen, denen ich folge, Nachrichten senden + Bei den Relays einer gefolgten Person anmelden, um DMs, Antworten und Benachrichtigungen zu senden. + Jedem Nachrichten senden + Bei den Relays von Fremden anmelden, um DMs, Antworten und Benachrichtigungen zu senden. Standardmäßig aus; stattdessen wirst du jedes Mal gefragt. + Diesem Relay bestätigen, dass du es bist? + Dieses Relay möchte zuerst bestätigen, dass du es wirklich bist. Sein Betreiber sieht, welches Konto du bist. + Deine Nachricht an %1$s senden? + %1$s benachrichtigen? + Beiträge von %1$s laden? + In %1$s posten? + %1$s öffnen? + Wenn nicht, wird deine Nachricht an %1$s nicht zugestellt. + Wenn nicht, wird %1$s nicht darüber benachrichtigt. + Wenn nicht, siehst du hier keine Beiträge von %1$s. + Wenn nicht, wird deine Nachricht an %1$s nicht gepostet. + Wenn nicht, siehst du %1$s hier nicht. + %1$s und andere + Deine private Nachricht senden an: + Benachrichtigen: + Beiträge herunterladen von: + In diesem Raum posten + Diesen Raum öffnen + Dieses Relay verwenden + Mit deinem eigenen Relay verbinden + Einmal erlauben + Dieses Relay immer erlauben + Meine Nachrichten immer zustellen + Dieses Relay blockieren + Relay-spezifische Ausnahmen + Vergessen + Zuletzt verwendet vor %1$s + Hier ist noch nichts — deine globale Richtlinie gilt für jedes Relay. + Erlauben + Verweigern Quelle: %1$s v%1$s Herunterladen @@ -883,6 +984,103 @@ Podcasts Folgen ansehen Noch keine Folgen gefunden + Staffel %1$d + Anstößig + Abgeschlossen + Die Show unterstützen + von %1$s + Staffel %1$d + Transkript + Kapitel + Zaps hierauf werden aufgeteilt zwischen: + Hosts & Gäste + Highlight abspielen + Top-Unterstützer + + %1$d Kapitel + %1$d Kapitel + + Co-Host + Redakteur + Verifizierter Autor + Value-for-Value-Fehler + Dieser Podcast hat keine zahlbaren Value-Empfänger. + Verbinde eine Nostr-Wallet-Connect-Wallet, um an Keysend-(Node-)Empfänger zu senden. + Sats streamen + %1$d Sats/Min + Sendet automatisch Value, während du zuhörst. + %1$d Sats in dieser Sitzung gestreamt + Verbinde eine Nostr-Wallet-Connect- oder Debit-Wallet, um Sats beim Zuhören zu streamen. + Neue Episode + Episode bearbeiten + Wird veröffentlicht… + Cover hinzufügen + Quadratisches Bild, das für die Episode angezeigt wird + Titel + Episodentitel + Zusammenfassung + Dauer (Sekunden) + Weitere Details + Staffel + Video-URL + Transkript-URL + Kapitel-URL + Themen + durch, Komma, getrennte, Tags + Audio-URL + Audiodatei bereit zum Hochladen + Audiodatei hinzufügen + MP3, M4A oder anderes Audio + Episode löschen + Diese Episode löschen? Das kann nicht rückgängig gemacht werden. + Dein Podcast + Cover hinzufügen + Quadratisches Cover für deine Show + Show-Titel + Mein Podcast + Beschreibung + Autor + Kontakt-E-Mail + Kategorien + Technik, Nachrichten + Finanzierungslinks + Sprache + de + Show-Typ + Episodisch + Serie + Anstößige Inhalte + Show abgeschlossen (keine weiteren Episoden) + Gesperrt (Premium) + Neuer Trailer + Trailer-Clip hinzufügen + Kurze Audio- oder Videovorschau + Trailer-Titel + Medien-URL + Dein Podcast + Ohne Titel + Noch keine Episoden. Tippe auf Neue Episode, um deine erste zu veröffentlichen. + Erstelle deinen Podcast + Tippen, um Show-Details zu bearbeiten + Lege Titel, Cover und Details deiner Show fest + + %1$d Trailer + %1$d Trailer + + Füge Empfänger hinzu, um eingehende Sats nach Gewichtung aufzuteilen. Hörer boosten oder streamen Value an diese Ziele. + Empfänger hinzufügen + Adresse manuell hinzufügen + Nostr-Nutzer hinzufügen + Nach Name oder @Handle suchen + Dieser Nutzer hat keine Lightning-Adresse + Empfänger entfernen + Lightning-Adresse + Node (Keysend) + Lightning-Adresse + Node-Pubkey + 02abc… (33-Byte-Hex) + Gewichtung + Gebühr %1$d Folge %1$d Folgen @@ -911,6 +1109,9 @@ Private Lesezeichen Öffentliche Lesezeichen + Repositorys + Deine gespeicherten Git-Repositorys + Deine gespeicherten Podcasts und Episoden Zu den privaten Lesezeichen hinzufügen Zu den öffentlichen Lesezeichen hinzufügen Aus den privaten Lesezeichen entfernen @@ -1456,6 +1657,21 @@ Wallet Verbindung Sprache Design + Akzentfarbe + Hauptfarbe für Schaltflächen und Links + Lila + Blau + Grün + Rot + Schriftart + Schrift, die in der gesamten App verwendet wird + Systemstandard + Serifenlos + Schriftgröße + Skaliert die Textgröße in der gesamten App + Klein + Groß + Riesig Bilder/GIFs automatisch laden Videos automatisch abspielen Videos automatisch abspielen @@ -1528,6 +1744,104 @@ Öffentlich Gruppe Neue öffentliche oder private Gruppe + Relay-Gruppen + Nach Relay + Zeigt jede Gruppe als eigene Unterhaltung, gemischt mit deinen Chats. + Fasst die Gruppen jedes Relays in einer einzigen Zeile zusammen, platziert bei seiner neuesten Nachricht. + NIP-29-Gruppenanzeige + Relay-Gruppen + Gruppen + Nachrichten + Eine Gruppe erstellen + Dieses Relay wirbt nicht mit Unterstützung für NIP-29-Relay-Gruppen. Eine hier erstellte Gruppe funktioniert nicht — ihr Name, ihre Mitglieder und Nachrichten werden nicht vom Relay verwaltet. Wähle ein Relay, das relaybasierte Gruppen unterstützt. + Gruppenname + Thema (optional) + Privat (nur Mitglieder lesen) + Nur mit Einladung + Erstellen + Personen einladen + Teile diesen Code, damit Personen dieser Gruppe beitreten können. + Einladung wird erstellt… + Einladungscode kopiert + Einladungscode (für diese Gruppe nur mit Einladung): + Gruppe beitreten + Einladungscode + Diese Gruppe ist nur mit Einladung. Gib den Code ein, den du erhalten hast. + Beitreten + Angefragt + Mitglied + Mitglieder + Zum Admin machen + Zum Moderator machen + Rolle entfernen + Aus Gruppe entfernen + %1$s aus dieser Gruppe entfernen? Sie verlieren den Zugriff, bis sie erneut hinzugefügt oder eingeladen werden. + Gruppe bearbeiten + Gruppenname + Thema (optional) + Privat (nur Mitglieder lesen) + Nur mit Einladung + Speichern + Mitglieder + Gruppe bearbeiten + Gruppe öffnen + Noch keine Gruppen auf diesem Relay. + Einladung wird vorbereitet… + Privat + Nur mit Einladung + Gib eine gültige Relay-URL ein (wss://…). + Beschreibung + Gruppenbild + Foto hinzufügen + Foto ändern + Entdeckung + Hilf Personen, diese Gruppe zu finden. Themen und ein Ort lassen sie unter den passenden Entdeckungsfiltern erscheinen (falls das Host-Relay sie unterstützt). + Themen + bitcoin, nostr, kunst + Ort (Geohash) + Berechtigungen + Privat + Nur Mitglieder können Nachrichten lesen. + Nur mit Einladung + Personen müssen eingeladen oder genehmigt werden, um beizutreten. + Nur Mitglieder posten + Nur Mitglieder können Nachrichten posten. + Nicht gelistet + Diese Gruppe aus dem öffentlichen Verzeichnis des Relays ausblenden. + Es wird nach Gruppen über deine Relays gesucht… + Noch keine Gruppen für diesen Filter gefunden. + Dieses Relay favorisieren + Noch keine Threads. Starte einen mit der +-Schaltfläche. + Neuer Thread + Ohne Titel + Titel + Schreibe etwas… + Posten + Gruppen finden + Durchsuche die auf einem Relay gehosteten Gruppen. Füge eine Relay-Adresse ein oder wähle unten eine aus. + Relay-Adresse + Durchsuchen + Relays, auf denen du bist + Beliebte Relays + Noch keine Nachrichten + Tritt dieser Gruppe bei, um Nachrichten zu senden. + Diese Gruppe ist nur mit Einladung — du brauchst eine Einladung, um zu posten. + + %1$d Mitglied + %1$d Mitglieder + + + %1$d Nachricht + %1$d Nachrichten + + + %1$d+ Nachricht + %1$d+ Nachrichten + + + %1$d Person, der du folgst + %1$d Personen, denen du folgst + Privat An Betreff @@ -1767,12 +2081,41 @@ Erstelle eine Cashu-Wallet, um Ecash-Tokens zu halten und Nutzaps zu empfangen. Suche nach deiner Wallet… NIP-60-Wallets werden über Clients hinweg synchronisiert. Wenn du in einer anderen App eine mit diesem Nostr-Schlüssel erstellt hast, sollte sie in wenigen Sekunden erscheinen. + Richte deine Wallet ein + Deine Wallet wird gesucht… + Wir durchsuchen jedes Relay nach einer Cashu-Wallet, die unter deinem Nostr-Schlüssel veröffentlicht wurde. + Relays werden durchsucht… %1$d / %2$d + Die gefundenen Wallets werden überprüft… + Keine Wallet gefunden + Wir konnten auf keinem Relay eine bestehende Cashu-Wallet unter deinem Nostr-Schlüssel finden. Erstelle eine neue, um zu beginnen. + Neue Wallet erstellen + Wir haben deine Wallet gefunden + Diese Cashu-Wallet ist im Nostr-Netzwerk unter deinem Schlüssel veröffentlicht. Nutze sie auf diesem Gerät — wir senden sie erneut an deine Relays, damit sie beim nächsten Mal leicht zu finden ist. + Diese Wallet verwenden + Wir haben mehrere Wallets gefunden + Du hast mehr als eine Cashu-Wallet im Netzwerk — höchstwahrscheinlich von verschiedenen Apps erstellt. Die neueste wird zu deiner Haupt-Wallet; übertrage alle Guthaben aus den älteren in sie. + Neueste — deine Haupt-Wallet + Ältere Wallet + Als Haupt-Wallet festlegen + Guthaben in Haupt-Wallet übertragen + %1$s Sats wiederherstellbar + %1$s Sats wiederhergestellt + Keine wiederherstellbaren Guthaben + Diese Wallet konnte nicht gelesen werden + Lege zuerst deine Haupt-Wallet fest, übertrage dann die Guthaben aus den älteren. + Deine Wallet wird eingerichtet… + Erneut suchen + Wallet erstellt + Wähle nun ein paar Mints, die deine Sats verwahren. + Mints auswählen Guthaben Mints Mint-URL Mint entfernen Mint hinzufügen Verlauf + Deine Wallet wird automatisch gespeichert, während du Mints hinzufügst oder entfernst. Ein Nutzap-Schlüssel wird für dich erstellt, sobald du das erste Mint hinzufügst. + Wird gespeichert… Nutzap-Schlüssel (erweitert) Ein separater privater Schlüssel, der nur zum Empfangen von NIP-61-Nutzaps dient. Nicht dein Nostr-Identitätsschlüssel. Neuen Schlüssel erzeugen @@ -2106,6 +2449,7 @@ App-Empfehlungen Erhaltene-Zaps-Feed Follower-Feed + Bitcoin-(On-Chain-)Wallet Reaktions-Einstellungen Konfigurieren Sie, welche Reaktionsschaltflächen angezeigt werden, ihre Reihenfolge und ob Zähler angezeigt werden sollen. Aktiviert @@ -2254,6 +2598,8 @@ Geforkt von Internet: Klonen: + Merge-Basis + Der Pull-Request wurde auf einen neuen Commit aktualisiert. Offen Zusammengeführt Geschlossen @@ -2261,8 +2607,18 @@ Übersicht Tickets Patches und PRs + Offen + Geschlossen & erledigt + Alle + Repository als Lesezeichen speichern Dateien + Aktualisiert + Letzte Aktivität + Betreut von + Extern gehostet + Dieses Repository kann nicht über http(s) geklont werden, daher ist die Code-Ansicht hier nicht verfügbar. Im Browser öffnen + Ohne Titel Über Links Maintainer @@ -2270,18 +2626,55 @@ Persönlicher Fork Readme Code + Repository wird geladen… + Das Repository konnte nicht von seiner Klon-URL geladen werden. + Diese Repository-Ankündigung enthält keine http(s)-Klon-URL zum Durchsuchen. + Dieses Repository hat keine README-Datei. + Diese Datei konnte nicht geladen werden. + Binärdatei (%1$s) — keine Vorschau verfügbar. + Dieser Ordner ist leer. Root + Erneut versuchen default Branches + Dateien durchsuchen… + Keine passenden Dateien. Commits Kein Commit-Verlauf verfügbar. + Dateiinhalt kopieren Text + + %1$d Element + %1$d Elemente + Datei geändert Dateien geändert + Binärdatei wird nicht angezeigt. + Neu + Neues Issue + Titel + Beschreibung + Erstellen + Abbrechen + Bug, Verbesserung… + Issue schließen + Wieder öffnen + Schließen + Wieder öffnen + Als gemergt markieren + Änderungen ansehen + Änderungen werden geladen… + Keine Dateiänderungen gefunden. + Änderungen erneut laden + Überarbeitet + Repository bearbeiten Name Beschreibung + Klon-URLs (eine pro Zeile) + Web-URLs (eine pro Zeile) + Themen (durch Komma getrennt) Speichern Git Repositories Statische Website: %1$s @@ -2856,6 +3249,7 @@ %1$s finanziert von %2$s Sats Ziel Endet %1$s On-Chain-Spende + Vogelerkennung Birdex · %1$d Art Birdex · %1$d Arten @@ -2865,6 +3259,9 @@ %1$s +%2$d weitere + PS1-Memory-Card-Spielstand + Block %1$d + Leerer Slot Polizei Blitzer @@ -2993,6 +3390,85 @@ Editor-Einstellungen Entwürfe automatisch erstellen Speichert automatisch ein Entwurfsereignis, wenn du tippst oder den Editor mit ungesendetem Text verlässt, und sendet es an deine privaten ausgehenden Relays. + Signatur + Wird am Ende der Nachricht angefügt, wenn du einen neuen Beitrag, eine Antwort, ein Zitat oder einen Artikel öffnest. Leer lassen, um zu deaktivieren. + Deine Signatur + Arbeitsnachweis + Schwierigkeit + Schürft vor der Veröffentlichung einen NIP-13-Arbeitsnachweis in deine Beiträge, damit Relays und Leser sie gegen Spam gewichten können. Höhere Werte dauern exponentiell länger zum Schürfen. Beiträge werden nach dem Schürfen im Hintergrund veröffentlicht. + Aus + Benutzerdefinierte Schwierigkeit + Feinabstimmung des Ziels in führenden Nullbits. + Was geschürft wird + Zeitkritische Ereignisse (Relay-Authentifizierung, Zap-Anfragen, Wallet- und Signierer-Nachrichten, Entwürfe, Listen) werden nie gemint. + Kurze Notizen & Antworten + Die primäre öffentliche Spam-Fläche + Kommentare + Antworten auf Artikel, Dateien und andere Inhalte + Meldungen + Relays gewichten Meldungen nach ihren Kosten + Langform & Highlights + Artikel und Highlights; selten, die Kosten sind vernachlässigbar + Sprachnachrichten + Öffentliche Sprachbeiträge und -antworten + Hohes Volumen bei aktiven Nutzern + Reaktionen + Häufigster Typ; jedes Like zu schürfen kostet Akku + Öffentlicher & Live-Chat + Schürf-Verzögerungen stören den Gesprächsfluss + Verpackungen privater Nachrichten + Ermöglicht DM-Relays das Filtern von Posteingangs-Spam; nur die äußere Verpackung wird gemint, nie deine Nachricht + Andere öffentliche Inhalte + Umfragen, Live-Status, Kleinanzeigen und alles andere Öffentliche + Arbeitsnachweis wird geschürft + + Arbeitsnachweis wird geschürft… (%1$d Beitrag in der Warteschlange) + Arbeitsnachweis wird geschürft… (%1$d Beiträge in der Warteschlange) + + + %1$s • schürft bei %2$d Bit + %1$s • schürft bei %2$d Bits + + + %1$s • wartet auf Schürfen bei %2$d Bit + %1$s • wartet auf Schürfen bei %2$d Bits + + + Standard (%1$d Bit) + Standard (%1$d Bits) + + Standard (aus) + Für diesen Beitrag aus + + %1$d Bit + %1$d Bits + + Arbeitsnachweis-Schürfen + Zeigt Beiträge an, die noch ihren NIP-13-Arbeitsnachweis schürfen, damit sie fertig werden können, nachdem du die App verlässt. + Abbrechen + ≈ %1$s pro Beitrag auf diesem Gerät + + %1$d Sekunde + %1$d Sekunden + + + %1$d Minute + %1$d Minuten + + + %1$d Stunde + %1$d Stunden + + + %1$d Tag + %1$d Tage + + ≈ %1$s verbleibend + jeden Moment + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden: %2$s + Dein %1$s wurde fertig geschürft, konnte aber nicht veröffentlicht werden. Es wird beim nächsten Start der App erneut versucht. + Chat-Nachricht + Meldung Verwenden Schließen Korrigieren diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index b12e17d4b0..2a7f29143b 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -583,6 +583,10 @@ Manipula por Disponível em + Categorias + Relacionados + Implementa + NIPs suportados Web Android iOS @@ -637,6 +641,9 @@ Abrir Limpar Favoritos + Descobrir web apps + Sites de pessoas que você segue + Apps de pessoas que você segue Opções Remover do histórico Apps favoritos @@ -662,6 +669,7 @@ Esquecer este nApplet Bloqueado Revogar + Perguntar sempre Nenhum nApplet encontrado ainda. @@ -707,14 +715,106 @@ Este nApplet quer pagar uma fatura Lightning de %1$d sats. + Conectar ao Nostr + quer se conectar à sua conta Nostr + Como as solicitações deste app devem ser tratadas? + Conectar + Bloquear e ignorar %1$s + Confio totalmente + Assinar automaticamente todas as solicitações (exceto pagamentos) + Vamos ser razoáveis + Aprovar automaticamente as solicitações mais comuns + Sou um pouco paranoico + Não assine nada sem me perguntar! + quer %1$s + Mostrar evento + Ocultar evento + Mais opções + Menos opções + Permitir uma vez + Permitir nesta sessão + Permitir por 24 horas + Permitir por 30 dias + Não perguntar novamente para %1$s + Não perguntar novamente para nenhuma solicitação Nostr + Negar + Sempre negar %1$s + Usado por último + Expira + assinar evento kind %1$d + assinar para %1$s (kind: %2$d) + criptografar uma mensagem + ler suas mensagens privadas + Apps conectados + Revogar todas as permissões + Substituições de operação + Nenhum app se conectou ainda. + Revogar todas as permissões + Permitir + Perguntar + Negar + apps permissões assinador napplet nsite webapp confiança conectados + Nível de confiança para assinatura + Capacidades + Esquecer este app + Substituições de operação de assinatura + Nenhum app se conectou ainda.\n\nQuando um web app se conectar à sua chave Nostr, ele aparecerá aqui. + Autenticação de relay + auth autenticação relay assinar verificar nip-42 identidade + Quando autenticar + Em qual fazer login + Sempre autenticar + Assinar desafios de autenticação de todos os relays que solicitarem + Nunca autenticar + Ignorar desafios de autenticação de todos os relays + Personalizado + Escolha exatamente em quais relays fazer login. Você será perguntado sobre qualquer um que não tenha permitido abaixo. + Meus relays e salas + Fazer login nos seus próprios relays e em chats públicos, comunidades e transmissões ao vivo que você entrou ou favoritou. + Ler publicações de pessoas que sigo + Fazer login nos relays de quem você segue para baixar as publicações deles. + Enviar mensagem a pessoas que sigo + Fazer login nos relays de quem você segue para enviar DMs, respostas e notificações. + Enviar mensagem a qualquer pessoa + Fazer login nos relays de desconhecidos para enviar DMs, respostas e notificações. Desativado por padrão; em vez disso, você será perguntado a cada vez. + Confirmar que é você para este relay? + Este relay quer confirmar que é realmente você primeiro. Seu operador verá qual conta você é. + Enviar sua mensagem para %1$s? + Notificar %1$s? + Carregar publicações de %1$s? + Publicar em %1$s? + Abrir %1$s? + Se não fizer isso, sua mensagem para %1$s não será entregue. + Se não fizer isso, %1$s não será notificado sobre isto. + Se não fizer isso, você não verá publicações de %1$s aqui. + Se não fizer isso, sua mensagem para %1$s não será publicada. + Se não fizer isso, você não verá %1$s aqui. + %1$s e outros + Enviar sua mensagem privada para: + Notificar: + Baixar publicações de: + Publicar nesta sala + Abrir esta sala + Usar este relay + Conectar ao seu próprio relay + Permitir uma vez + Sempre permitir este relay + Sempre entregar minhas mensagens + Bloquear este relay + Substituições por relay + Esquecer + Usado por último há %1$s + Nada aqui ainda — sua política global se aplica a todos os relays. + Permitir + Negar Fonte: %1$s v%1$s Baixar @@ -879,6 +979,105 @@ Podcasts Ver episódios Nenhum episódio encontrado ainda + Temporada %1$d + Explícito + Concluído + Apoiar o programa + por %1$s + T%1$d · E%2$d + Temporada %1$d + Vídeo + Transcrição + Capítulos + Os zaps para isto são divididos entre: + Apresentadores & Convidados + Reproduzir destaque + Principais apoiadores + + %1$d capítulo + %1$d capítulos + + Apresentador + Coapresentador + Autor verificado + Erro de Value-for-Value + Este podcast não tem destinatários de valor pagáveis. + Conecte uma carteira Nostr Wallet Connect para enviar a destinatários keysend (nó). + Transmitir sats + Envia valor automaticamente enquanto você ouve. + %1$d sats transmitidos nesta sessão + Conecte uma carteira Nostr Wallet Connect ou de débito para transmitir sats enquanto ouve. + Novo episódio + Editar episódio + Publicando… + Adicionar capa + Imagem quadrada exibida para o episódio + Título + Título do episódio + Resumo + Duração (segundos) + Mais detalhes + Temporada + Episódio nº + URL do vídeo + URL da transcrição + URL dos capítulos + Tópicos + tags, separadas, por vírgula + URL do áudio + https://…/episodio.mp3 + Arquivo de áudio pronto para upload + Adicionar arquivo de áudio + MP3, M4A ou outro áudio + Excluir episódio + Excluir este episódio? Isso não pode ser desfeito. + Seu podcast + Adicionar capa + Arte quadrada para o seu programa + Título do programa + Meu Podcast + Descrição + Autor + E-mail de contato + Site + Categorias + Tecnologia, Notícias + Links de financiamento + Idioma + pt + Direitos autorais + Tipo de programa + Episódico + Conteúdo explícito + Programa concluído (sem mais episódios) + Bloqueado (premium) + Novo trailer + Adicionar clipe de trailer + Prévia curta de áudio ou vídeo + Título do trailer + URL da mídia + Seu podcast + Sem título + Nenhum episódio ainda. Toque em Novo episódio para publicar o primeiro. + Crie seu podcast + Toque para editar os detalhes do programa + Configure o título, a arte e os detalhes do seu programa + Adicione destinatários para dividir os sats recebidos por peso. Os ouvintes impulsionam ou transmitem valor para esses destinos. + Adicionar destinatário + Adicionar endereço manualmente + Adicionar um usuário Nostr + Pesquise por nome ou @handle + Este usuário não tem endereço Lightning + Remover destinatário + Nome (opcional) + Endereço Lightning + Nó (keysend) + Endereço Lightning + nome@exemplo.com + Chave pública do nó + 02abc… (33 bytes em hex) + Peso + Taxa %1$d episódio %1$d episódios @@ -907,6 +1106,9 @@ Itens Salvos Privados Itens Salvos Públicos + Repositórios + Seus repositórios git marcados + Seus podcasts e episódios marcados Adicionar aos Itens Salvos Privados Adicionar aos Itens Salvos Públicos Remover dos Itens Salvos Privados @@ -1452,6 +1654,25 @@ Conectar Carteira Linguagem Tema + Cor de destaque + Cor principal usada em botões e links + Roxo + Azul + Verde + Laranja + Vermelho + Rosa + Fonte + Tipo de letra usado em todo o app + Padrão do sistema + Sem serifa + Serifada + Monoespaçada + Tamanho da fonte + Ajuste o tamanho do texto em todo o app + Pequeno + Grande + Enorme Pré-visualizar imagens Reproduzir vídeos automaticamente Reprodução automática de vídeos @@ -1524,6 +1745,107 @@ Público Grupo Novo Grupo Público ou Privado + Grupos de relay + Em linha + Por relay + Mostra cada grupo como sua própria conversa, misturado com seus chats. + Agrupa os grupos de cada relay em uma única linha, posicionada na sua mensagem mais recente. + Exibição de grupos NIP-29 + Grupos de relay + Grupos + Mensagens + Criar um grupo + Este relay não anuncia suporte a grupos NIP-29. Um grupo criado aqui não funcionará — seu nome, membros e mensagens não serão gerenciados pelo relay. Escolha um relay que suporte grupos baseados em relay. + Nome do grupo + Tópico (opcional) + Privado (leitura somente para membros) + Somente por convite + Criar + Convidar pessoas + Compartilhe este código para que as pessoas possam entrar neste grupo. + Criando um convite… + Código de convite copiado + Código de convite (para este grupo somente por convite): + Entrar no grupo + Código de convite + Este grupo é somente por convite. Insira o código que você recebeu. + Entrar + Solicitado + Moderador + Membro + Membros + Tornar admin + Tornar moderador + Remover função + Remover do grupo + Remover %1$s deste grupo? A pessoa perderá o acesso até ser readicionada ou reconvidada. + Editar grupo + Nome do grupo + Tópico (opcional) + Privado (leitura somente para membros) + Somente por convite + Salvar + Membros + Editar grupo + Abrir grupo + Nenhum grupo neste relay ainda. + Preparando convite… + Privado + Somente por convite + Insira uma URL de relay válida (wss://…). + Nome + Descrição + Imagem do grupo + Adicionar foto + Alterar foto + Descoberta + Ajude as pessoas a encontrar este grupo. Tópicos e uma localização fazem-no aparecer nos filtros de descoberta correspondentes (se o relay host os suportar). + Tópicos + bitcoin, nostr, arte + Localização (geohash) + Permissões + Privado + Apenas membros podem ler as mensagens. + Somente por convite + As pessoas precisam ser convidadas ou aprovadas para entrar. + Publicação somente para membros + Apenas membros podem publicar mensagens. + Não listado + Ocultar este grupo do diretório público do relay. + Procurando grupos nos seus relays… + Nenhum grupo encontrado para este filtro ainda. + Favoritar este relay + Nenhuma thread ainda. Comece uma com o botão +. + Nova thread + Sem título + Título + Escreva algo… + Publicar + Encontrar grupos + Explore os grupos hospedados em um relay. Cole um endereço de relay ou escolha um abaixo. + Endereço do relay + Explorar + Relays em que você está + Relays populares + Nenhuma mensagem ainda + Entre neste grupo para enviar mensagens. + Este grupo é somente por convite — você precisa de um convite para publicar. + + %1$d membro + %1$d membros + + + %1$d mensagem + %1$d mensagens + + + %1$d+ mensagem + %1$d+ mensagens + + + %1$d pessoa que você segue + %1$d pessoas que você segue + Privado Para Assunto @@ -1763,12 +2085,41 @@ Crie uma carteira Cashu para guardar tokens ecash e receber nutzaps. Procurando sua carteira… Carteiras NIP-60 sincronizam entre clientes. Se você criou uma em outro app com esta chave Nostr, ela deve aparecer em alguns segundos. + Configure sua carteira + Procurando sua carteira… + Procurando em todos os relays por uma carteira Cashu publicada sob sua chave Nostr. + Procurando em relays… %1$d / %2$d + Verificando as carteiras que encontramos… + Nenhuma carteira encontrada + Não encontramos uma carteira Cashu existente sob sua chave Nostr em nenhum relay. Crie uma nova para começar. + Criar uma nova carteira + Encontramos sua carteira + Esta carteira Cashu está publicada na rede Nostr sob sua chave. Use-a neste dispositivo — vamos retransmiti-la para seus relays para que seja fácil encontrá-la da próxima vez. + Usar esta carteira + Encontramos várias carteiras + Você tem mais de uma carteira Cashu na rede — provavelmente criadas por apps diferentes. A mais recente se torna sua carteira principal; recupere para ela os fundos das mais antigas. + Mais recente — sua carteira principal + Carteira mais antiga + Definir como carteira principal + Recuperar fundos para a carteira principal + %1$s sats recuperáveis + %1$s sats recuperados + Nenhum fundo recuperável + Não foi possível ler esta carteira + Defina primeiro sua carteira principal, depois recupere os fundos das mais antigas. + Configurando sua carteira… + Buscar novamente + Carteira criada + Agora escolha alguns mints para hospedar seus sats. + Escolher mints Saldo Mints URL do mint Remover mint Adicionar mint Histórico + Sua carteira é salva automaticamente conforme você adiciona ou remove mints. Uma chave de nutzap é criada para você na primeira vez que adiciona um mint. + Salvando… Chave de nutzap (avançado) Uma chave privada separada, usada apenas para receber nutzaps NIP-61. Não é a chave da sua identidade Nostr. Gerar uma nova chave @@ -2102,6 +2453,7 @@ Recomendações de Apps Feed de Zaps Recebidos Feed de Seguidores + Carteira Bitcoin (on-chain) Configurações de Reações Configure quais botões de reação são exibidos, sua ordem e se os contadores devem ser mostrados. Ativado @@ -2250,6 +2602,8 @@ Forkado de Site: Clonar: + Base de merge + O pull request foi atualizado para um novo commit. Aberto Mesclado Fechado @@ -2257,11 +2611,69 @@ Visão geral Tickets Patches e PRs + Abertos + Fechados & Resolvidos + Todos + Marcar repositório + Arquivos + Atualizado + Atividade recente + Mantido por + Hospedado externamente + Este repositório não pode ser clonado via http(s), então a visualização de código não está disponível aqui. + Abrir no navegador + Sem título Sobre Links Mantenedores Tópicos Fork pessoal + Código + Carregando repositório… + Não foi possível carregar o repositório a partir da sua URL de clone. + Este anúncio de repositório não tem URL de clone http(s) para navegar. + Este repositório não tem arquivo README. + Não foi possível carregar este arquivo. + Arquivo binário (%1$s) — prévia não disponível. + Esta pasta está vazia. + raiz + Tentar novamente + padrão + Pesquisar arquivos… + Nenhum arquivo correspondente. + Nenhum histórico de commits disponível. + Copiar conteúdo do arquivo + Texto + + %1$d arquivo alterado + %1$d arquivos alterados + + Arquivo binário não exibido. + Nova + Nova issue + Título + Descrição + Criar + Cancelar + bug, melhoria… + Fechar issue + Reabrir + Fechar + Reabrir + Marcar como merged + Ver alterações + Carregando alterações… + Nenhuma alteração de arquivo encontrada. + Tentar carregar alterações novamente + Revisado + Editar repositório + Nome + Descrição + URLs de clone (uma por linha) + URLs web (uma por linha) + Tópicos (separados por vírgula) + Salvar + Repositórios Git Site Estático: %1$s Permissões: Site Raiz @@ -2550,6 +2962,7 @@ Patch do Git Repositório Git Resposta do Git + Atualização de PR Metas de Zap Hashtags seguidas Destaques @@ -2832,6 +3245,7 @@ %1$s financiado de %2$s sats da meta Termina %1$s Doação on-chain + Detecção de pássaros Birdex · %1$d espécie Birdex · %1$d espécies @@ -2841,6 +3255,9 @@ %1$s +%2$d a mais + Salvamento em cartão de memória PS1 + bloco %1$d + Espaço vazio Polícia Radar de velocidade @@ -2969,6 +3386,82 @@ Configurações de composição Criar rascunhos automaticamente Salva um evento de rascunho automaticamente quando você digita ou sai do compositor com texto não enviado e envia para seus relays de saída privados. + Assinatura + Adicionado ao final da mensagem ao abrir uma nova publicação, resposta, citação ou artigo. Deixe em branco para desativar. + Sua assinatura + Prova de trabalho + Dificuldade + Minera uma prova de trabalho NIP-13 nas suas publicações antes de publicar, para que relays e leitores possam ponderá-las contra spam. Valores mais altos levam exponencialmente mais tempo para minerar. As publicações são publicadas em segundo plano depois de mineradas. + Desligado + Dificuldade personalizada + Ajuste com precisão o alvo em bits zero à esquerda. + O que minerar + Eventos com prazo crítico (autenticação de relay, solicitações de zap, mensagens de carteira e assinador, rascunhos, listas) nunca são minerados. + Notas curtas & respostas + A principal superfície de spam público + Comentários + Respostas a artigos, arquivos e outros conteúdos + Denúncias + Os relays ponderam as denúncias pelo seu custo + Conteúdo longo & destaques + Artigos e destaques; pouco frequentes, o custo é insignificante + Mensagens de voz + Publicações e respostas de voz públicas + Republicações + Alto volume para usuários ativos + Reações + Kind de maior volume; minerar cada curtida gasta bateria + Chat público & ao vivo + Atrasos de mineração prejudicam o fluxo da conversa + Envelopes de mensagens privadas + Permite que os relays de DM filtrem spam da caixa de entrada; apenas o envelope externo é minerado, nunca sua mensagem + Outro conteúdo público + Enquetes, status ao vivo, classificados e tudo mais que for público + Minerando prova de trabalho + + Minerando prova de trabalho… (%1$d publicação na fila) + Minerando prova de trabalho… (%1$d publicações na fila) + + + %1$s • minerando em %2$d bit + %1$s • minerando em %2$d bits + + + %1$s • aguardando para minerar em %2$d bit + %1$s • aguardando para minerar em %2$d bits + + + Padrão (%1$d bit) + Padrão (%1$d bits) + + Padrão (desligado) + Desligado para esta publicação + Mineração de prova de trabalho + Mostra publicações que ainda estão minerando sua prova de trabalho NIP-13 para que possam terminar depois que você sair do app. + Cancelar + ≈ %1$s por publicação neste dispositivo + + %1$d segundo + %1$d segundos + + + %1$d minuto + %1$d minutos + + + %1$d hora + %1$d horas + + + %1$d dia + %1$d dias + + ≈ %1$s restante + a qualquer momento + Seu %1$s terminou de minerar, mas não pôde ser publicado: %2$s + Seu %1$s terminou de minerar, mas não pôde ser publicado. Uma nova tentativa será feita na próxima vez que o app iniciar. + Mensagem de chat + Denúncia Usar isto Dispensar Corrigir diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index ca69e77142..83a1991f8d 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -583,6 +583,10 @@ Hanterar av Tillgänglig på + Kategorier + Relaterat + Implementerar + NIP:er som stöds Web Android iOS @@ -638,6 +642,9 @@ Öppna Rensa Favoriter + Upptäck webbappar + Webbplatser från personer du följer + Appar från personer du följer Alternativ Ta bort från historik Favoritappar @@ -663,6 +670,7 @@ Glöm det här nApplet Blockerad Återkalla + Fråga mig varje gång Inga nApplets hittades ännu. @@ -710,14 +718,106 @@ Det här nApplet vill betala en Lightning-faktura på %1$d sats. + Anslut till Nostr + vill ansluta till ditt Nostr-konto + Hur ska den här appens förfrågningar hanteras? + Anslut + Blockera och ignorera %1$s + Jag litar helt på den + Signera automatiskt alla förfrågningar (utom betalningar) + Låt oss vara rimliga + Godkänn automatiskt de vanligaste förfrågningarna + Jag är lite paranoid + Signera inget utan att fråga mig! + vill %1$s + Visa händelse + Dölj händelse + Fler alternativ + Färre alternativ + Tillåt en gång + Tillåt för den här sessionen + Tillåt i 24 timmar + Tillåt i 30 dagar + Fråga inte igen om att %1$s + Fråga inte igen för några Nostr-förfrågningar + Neka + Neka alltid %1$s + Senast använd + Upphör + signera en händelse av typ %1$d + signera för %1$s (typ: %2$d) + kryptera ett meddelande + läsa dina privata meddelanden + Anslutna appar + Återkalla alla behörigheter + Åsidosättningar för operationer + Inga appar har anslutit ännu. + Återkalla alla behörigheter + Tillåt + Fråga + Neka + appar behörigheter signerare napplet nsite webapp förtroende anslutna + Förtroendenivå för signering + Behörigheter + Glöm den här appen + Åsidosättningar för signeringsoperationer + Inga appar har anslutit ännu.\n\nNär en webbapp ansluter till din Nostr-nyckel visas den här. + Reläautentisering + auth autentisering relä signera verifiera nip-42 identitet + När autentisering ska ske + Vad du ska logga in på + Autentisera alltid + Signera autentiseringsutmaningar för varje relä som begär det + Autentisera aldrig + Ignorera autentiseringsutmaningar från alla reläer + Anpassad + Välj exakt vilka reläer du vill logga in på. Du tillfrågas om allt du inte har tillåtit nedan. + Mina reläer och platser + Logga in på dina egna reläer och på offentliga chattar, communities och livestreams som du har gått med i eller favoritmarkerat. + Läs inlägg från personer jag följer + Logga in på en följd persons reläer för att ladda ner deras inlägg. + Meddela personer jag följer + Logga in på en följd persons reläer för att skicka DM, svar och notiser. + Meddela vem som helst + Logga in på främlingars reläer för att skicka DM, svar och notiser. Av som standard; du tillfrågas i stället varje gång. + Bekräfta att det är du för det här reläet? + Det här reläet vill först bekräfta att det verkligen är du. Dess operatör ser vilket konto du är. + Skicka ditt meddelande till %1$s? + Meddela %1$s? + Läs in inlägg från %1$s? + Publicera till %1$s? + Öppna %1$s? + Om du inte gör det levereras inte ditt meddelande till %1$s. + Om du inte gör det meddelas inte %1$s om detta. + Om du inte gör det ser du inte inlägg från %1$s här. + Om du inte gör det publiceras inte ditt meddelande till %1$s. + Om du inte gör det ser du inte %1$s här. + %1$s med flera + Skicka ditt privata meddelande till: + Meddela: + Ladda ner inlägg från: + Publicera till det här rummet + Öppna det här rummet + Använd det här reläet + Anslut till ditt eget relä + Tillåt en gång + Tillåt alltid det här reläet + Leverera alltid mina meddelanden + Blockera det här reläet + Åsidosättningar per relä + Glöm + Senast använd för %1$s sedan + Inget här ännu — din globala policy gäller för varje relä. + Tillåt + Neka Källa: %1$s v%1$s Ladda ner @@ -882,6 +982,105 @@ Poddar Visa avsnitt Inga avsnitt hittades än + Säsong %1$d + Slutförd + Stöd podden + av %1$s + S%1$d · A%2$d + Avs %1$d + Säsong %1$d + Transkription + Kapitel + Zaps till detta fördelas mellan: + Värdar & gäster + Spela höjdpunkt + Främsta supportrar + + %1$d kapitel + %1$d kapitel + + Värd + Medvärd + Redaktör + Verifierad författare + Value-for-Value-fel + Den här podden har inga betalbara värdemottagare. + Anslut en Nostr Wallet Connect-plånbok för att skicka till keysend-mottagare (noder). + Streama sats + Skickar värde automatiskt medan du lyssnar. + Streamade %1$d sats denna session + Anslut en Nostr Wallet Connect- eller debiteringsplånbok för att streama sats medan du lyssnar. + Nytt avsnitt + Redigera avsnitt + Publicerar… + Lägg till omslagsbild + Kvadratisk bild som visas för avsnittet + Titel + Avsnittstitel + Sammanfattning + Längd (sekunder) + Fler detaljer + Säsong + Avsnitt # + Video-URL + Transkriptions-URL + Kapitel-URL + Ämnen + komma, separerade, taggar + Ljud-URL + https://…/avsnitt.mp3 + Ljudfil redo att laddas upp + Lägg till ljudfil + MP3, M4A eller annat ljud + Ta bort avsnitt + Ta bort det här avsnittet? Detta kan inte ångras. + Din podd + Lägg till omslagsbild + Kvadratisk bild för din podd + Poddtitel + Min podd + Beskrivning + Författare + Kontakt-e-post + Webbplats + Kategorier + Teknik, Nyheter + Finansieringslänkar + Språk + sv + Upphovsrätt + Poddtyp + Fristående avsnitt + Seriell + Explicit innehåll + Podden är komplett (inga fler avsnitt) + Låst (premium) + Ny trailer + Lägg till trailerklipp + Kort ljud- eller videoförhandsvisning + Trailertitel + Media-URL + Din podd + Namnlös + Inga avsnitt ännu. Tryck på Nytt avsnitt för att publicera ditt första. + Skapa din podd + Tryck för att redigera podddetaljer + Ange din podds titel, bild och detaljer + Lägg till mottagare för att fördela inkommande sats efter vikt. Lyssnare boostar eller streamar värde till dessa destinationer. + Lägg till mottagare + Lägg till adress manuellt + Lägg till en Nostr-användare + Sök efter namn eller @handtag + Den här användaren har ingen Lightning-adress + Ta bort mottagare + Namn (valfritt) + Lightning-adress + Nod (keysend) + Lightning-adress + namn@example.com + Nodens publika nyckel + Vikt + Avgift %1$d avsnitt %1$d avsnitt @@ -910,6 +1109,9 @@ Privata Bokmärken Publika Bokmärken + Dina bokmärkta git-repositories + Poddar + Dina bokmärkta poddar och avsnitt Lägg till i Privata Bokmärken Lägg till i Publika Bokmärken Ta bort från Privata Bokmärken @@ -1455,6 +1657,21 @@ Plånboksanslut Språk Tema + Accentfärg + Huvudfärg som används för knappar och länkar + Lila + Blå + Grön + Röd + Rosa + Typsnitt + Teckensnitt som används genom hela appen + Systemstandard + Teckenstorlek + Skala textstorleken i hela appen + Liten + Stor + Enorm Ladda automatiskt bilder/gif Spela automatiskt videor Automatisk uppspelning av videor @@ -1527,6 +1744,106 @@ Publik Grupp Ny offentlig eller privat grupp + Relägrupper + Efter relä + Visa varje grupp som en egen konversation, blandad med dina chattar. + Slå ihop varje reläs grupper till en enda rad, placerad vid dess nyaste meddelande. + Visning av NIP-29-grupper + Relägrupper + Grupper + Meddelanden + Skapa en grupp + Det här reläet annonserar inte stöd för NIP-29-relägrupper. En grupp som skapas här fungerar inte — dess namn, medlemmar och meddelanden hanteras inte av reläet. Välj ett relä som stöder reläbaserade grupper. + Gruppnamn + Ämne (valfritt) + Privat (läsning endast för medlemmar) + Endast inbjudan + Skapa + Bjud in personer + Dela den här koden så att personer kan gå med i gruppen. + Skapar en inbjudan… + Inbjudningskod kopierad + Inbjudningskod (för den här gruppen med endast inbjudan): + Gå med i grupp + Inbjudningskod + Den här gruppen är endast för inbjudna. Ange koden du fick. + Gå med + Begärd + Medlem + Medlemmar + Gör till admin + Gör till moderator + Ta bort roll + Ta bort från grupp + Ta bort %1$s från den här gruppen? De förlorar åtkomst tills de läggs till eller bjuds in igen. + Redigera grupp + Gruppnamn + Ämne (valfritt) + Privat (läsning endast för medlemmar) + Endast inbjudan + Spara + Medlemmar + Redigera grupp + Trådar + Öppna grupp + Inga grupper på det här reläet ännu. + Förbereder inbjudan… + Privat + Endast inbjudan + Ange en giltig relä-URL (wss://…). + Namn + Beskrivning + Gruppbild + Lägg till foto + Ändra foto + Upptäckt + Hjälp personer att hitta den här gruppen. Ämnen och en plats gör att den syns under matchande upptäcktsfilter (om värdreläet stöder dem). + Ämnen + bitcoin, nostr, konst + Plats (geohash) + Behörigheter + Privat + Endast medlemmar kan läsa meddelanden. + Endast inbjudan + Personer måste bjudas in eller godkännas för att gå med. + Publicering endast för medlemmar + Endast medlemmar kan publicera meddelanden. + Ej listad + Dölj den här gruppen från reläets offentliga katalog. + Söker efter grupper på dina reläer… + Inga grupper hittades för det här filtret ännu. + Favoritmarkera det här reläet + Inga trådar ännu. Starta en med +-knappen. + Ny tråd + Namnlös + Titel + Skriv något… + Publicera + Hitta grupper + Bläddra bland grupperna som hostas på ett relä. Klistra in en reläadress eller välj en nedan. + Reläadress + Bläddra + Reläer du är på + Populära reläer + Inga meddelanden ännu + Gå med i den här gruppen för att skicka meddelanden. + Den här gruppen är endast för inbjudna — du behöver en inbjudan för att publicera. + + %1$d medlem + %1$d medlemmar + + + %1$d meddelande + %1$d meddelanden + + + %1$d+ meddelande + %1$d+ meddelanden + + + %1$d person du följer + %1$d personer du följer + Privat Till Ämne @@ -1766,12 +2083,41 @@ Skapa en Cashu-plånbok för att hålla ecash-tokens och ta emot nutzaps. Letar efter din plånbok… NIP-60-plånböcker synkroniseras mellan klienter. Om du skapade en i en annan app med den här Nostr-nyckeln bör den dyka upp inom några sekunder. + Konfigurera din plånbok + Söker efter din plånbok… + Söker på alla reläer efter en Cashu-plånbok publicerad under din Nostr-nyckel. + Söker på reläer… %1$d / %2$d + Verifierar plånböckerna vi hittade… + Ingen plånbok hittades + Vi kunde inte hitta någon befintlig Cashu-plånbok under din Nostr-nyckel på något relä. Skapa en ny för att komma igång. + Skapa en ny plånbok + Vi hittade din plånbok + Den här Cashu-plånboken är publicerad i Nostr-nätverket under din nyckel. Använd den på den här enheten — vi sänder om den till dina reläer så att den är lätt att hitta nästa gång. + Använd den här plånboken + Vi hittade flera plånböcker + Du har mer än en Cashu-plånbok i nätverket — troligen skapade av olika appar. Den nyaste blir din huvudplånbok; återhämta eventuella medel från de äldre till den. + Nyast — din huvudplånbok + Äldre plånbok + Ange som huvudplånbok + Återhämta medel till huvudplånboken + %1$s sats kan återvinnas + Återhämtade %1$s sats + Inga återvinningsbara medel + Det gick inte att läsa den här plånboken + Ange din huvudplånbok först, återhämta sedan medel från de äldre. + Konfigurerar din plånbok… + Sök igen + Plånbok skapad + Välj nu några mints som ska hosta dina sats. + Välj mints Saldo Mints Mint-URL Ta bort mint Lägg till mint Historik + Din plånbok sparas automatiskt när du lägger till eller tar bort mints. En nutzap-nyckel skapas åt dig första gången du lägger till en mint. + Sparar… Nutzap-nyckel (avancerat) En separat privat nyckel som endast används för att ta emot NIP-61-nutzaps. Inte din Nostr-identitetsnyckel. Generera en ny nyckel @@ -2105,6 +2451,7 @@ App-rekommendationer Mottagna zaps-flöde Följarflöde + Bitcoin-plånbok (on-chain) Reaktionsinställningar Konfigurera vilka reaktionsknappar som visas, deras ordning och om räknare ska visas. Aktiverad @@ -2253,6 +2600,9 @@ Forkad från Webbplats: Klona: + Gren + Mergebas + Uppdaterade pull request till en ny commit. Öppen Sammanfogad Stängd @@ -2260,11 +2610,77 @@ Översikt Ärenden Patchar och PR + Öppna + Stängda & lösta + Alla + Bokmärk repository + Grenar + Taggar + Filer + Uppdaterad + Senaste aktivitet + Underhålls av + Hostat externt + Det här repositoryt kan inte klonas över http(s), så kodvyn är inte tillgänglig här. + Öppna i webbläsare + Namnlös Om Länkar Underhållare Ämnen Personlig fork + Kod + Läser in repository… + Det gick inte att läsa in repositoryt från dess klon-URL. + Den här repository-annonseringen har ingen http(s)-klon-URL att bläddra i. + Det här repositoryt har ingen README-fil. + Det gick inte att läsa in den här filen. + Binär fil (%1$s) — förhandsvisning ej tillgänglig. + Den här mappen är tom. + rot + Försök igen + standard + Grenar + Taggar + Sök filer… + Inga matchande filer. + Ingen commit-historik tillgänglig. + Kopiera filinnehåll + + %1$d objekt + %1$d objekt + + + %1$d fil ändrad + %1$d filer ändrade + + Binär fil visas inte. + Ny + Nytt ärende + Titel + Beskrivning + Skapa + Avbryt + Etiketter + bugg, förbättring… + Stäng ärende + Öppna igen + Stäng + Öppna igen + Markera som mergad + Visa ändringar + Läser in ändringar… + Inga filändringar hittades. + Försök läsa in ändringar igen + Reviderad + Redigera repository + Namn + Beskrivning + Klon-URL:er (en per rad) + Webb-URL:er (en per rad) + Ämnen (kommaseparerade) + Spara + Git-repositories Statisk webbplats: %1$s Behörigheter: Appar & webbplatser @@ -2554,6 +2970,7 @@ Git-patch Git-repository Git-svar + PR-uppdatering Zap-mål Följda hashtaggar Höjdpunkter @@ -2836,6 +3253,7 @@ %1$s finansierat av %2$s sats mål Slutar %1$s On-chain-donation + Fågeldetektering Birdex · %1$d art Birdex · %1$d arter @@ -2845,6 +3263,8 @@ %1$s +%2$d till + PS1-minneskortsparning + Tom plats Polis Fartkamera @@ -2973,6 +3393,82 @@ Skrivinställningar Skapa utkast automatiskt Sparar ett utkast automatiskt när du skriver eller lämnar redigeraren med osänd text och skickar det till dina privata utgående reläer. + Signatur + Läggs till i slutet av meddelandet när du öppnar ett nytt inlägg, svar, citat eller artikel. Lämna tomt för att inaktivera. + Din signatur + Bevis på arbete + Svårighetsgrad + Minar ett NIP-13-bevis på arbete i dina inlägg innan de publiceras så att reläer och läsare kan väga dem mot spam. Högre värden tar exponentiellt längre tid att mina. Inlägg publiceras i bakgrunden när de är minade. + Av + Anpassad svårighetsgrad + Finjustera målet i inledande nollbitar. + Vad som ska minas + Tidskritiska händelser (relä-autentisering, zap-förfrågningar, plånboks- och signerarmeddelanden, utkast, listor) minas aldrig. + Korta notiser & svar + Den primära offentliga spamytan + Kommentarer + Svar på artiklar, filer och annat innehåll + Rapporter + Reläer väger rapporter efter deras kostnad + Långform & höjdpunkter + Artiklar och höjdpunkter; sällsynta, kostnaden är försumbar + Röstmeddelanden + Offentliga röstinlägg och svar + Reposter + Hög volym för aktiva användare + Reaktioner + Den mest högvolymiga typen; att mina varje gillning kostar batteri + Offentlig & livechatt + Miningfördröjningar skadar konversationsflödet + Omslag för privata meddelanden + Låter DM-reläer filtrera inkorgsspam; endast det yttre omslaget minas, aldrig ditt meddelande + Annat offentligt innehåll + Omröstningar, livestatus, radannonser och allt annat offentligt + Minar bevis på arbete + + Minar bevis på arbete… (%1$d inlägg i kön) + Minar bevis på arbete… (%1$d inlägg i kön) + + + %1$s • minar vid %2$d bit + %1$s • minar vid %2$d bitar + + + %1$s • väntar på att minas vid %2$d bit + %1$s • väntar på att minas vid %2$d bitar + + + Standard (%1$d bit) + Standard (%1$d bitar) + + Standard (av) + Av för det här inlägget + Mining av bevis på arbete + Visar inlägg som fortfarande minar sitt NIP-13-bevis på arbete så att de kan slutföras efter att du lämnat appen. + Avbryt + ≈ %1$s per inlägg på den här enheten + + %1$d sekund + %1$d sekunder + + + %1$d minut + %1$d minuter + + + %1$d timme + %1$d timmar + + + %1$d dag + %1$d dagar + + ≈ %1$s kvar + när som helst nu + Ditt %1$s slutförde miningen men kunde inte publiceras: %2$s + Ditt %1$s slutförde miningen men kunde inte publiceras. Det görs ett nytt försök nästa gång appen startar. + Chattmeddelande + Rapport Använd detta Avvisa Korrigera @@ -3055,6 +3551,10 @@ Inga inbjudningar När någon lägger till dig i en grupp visas det här tills du svarar. Inga meddelanden ännu + + %1$d medd + %1$d medd + %1$d medlem %1$d medlemmar diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 88e2ab67dc..b56145cbb7 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -178,6 +178,12 @@ "Spanish, United States" ] }, + { + "user": "davotoula", + "languages": [ + "Czech" + ] + }, { "user": "greenart7c3", "languages": [] @@ -242,10 +248,6 @@ "user": "adhrasreoshiathoi", "languages": [] }, - { - "user": "davotoula", - "languages": [] - }, { "user": "crackadoo", "languages": [] From 3cac9077ec97e486c2da664b5f50bf5277005058 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 15:12:32 +0000 Subject: [PATCH 070/206] 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 071/206] 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 0053edf2dd96d923d8f22c003eb7be6bc03d259b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 11 Jul 2026 16:49:41 +0100 Subject: [PATCH 072/206] refactor: adopt androidx.core KTX helpers (Bitmap/Uri/SharedPreferences) --- .claude/CLAUDE.md | 20 +++++++++++++++++++ .../amethyst/ImageUploadTesting.kt | 3 ++- .../ThumbnailDiskCacheInstrumentedTest.kt | 3 ++- .../amethyst/napplet/NappletBrokerService.kt | 3 ++- .../amethyst/napplet/WebAppNetworkRegistry.kt | 4 ++-- .../calendar/CalendarReminderNotifier.kt | 3 ++- .../service/calendar/CalendarReminderPrefs.kt | 5 +++-- .../service/calendar/CalendarReminderStore.kt | 3 ++- .../ui/note/creators/location/MapPinIcon.kt | 3 ++- .../amethyst/ui/note/types/Ps1Save.kt | 5 ++--- .../screen/loggedIn/browser/WebAppScreen.kt | 6 +++--- .../detail/CalendarEventDetailScreen.kt | 5 +++-- .../commons/blurhash/PlatformImage.android.kt | 3 ++- .../commons/keystorage/SecureKeyStorage.kt | 5 +++-- .../nip64Chess/ChessDismissedGamesStorage.kt | 3 ++- .../napplethost/NappletBrowserActivity.kt | 12 ++++++----- .../napplethost/NappletBrowserService.kt | 3 ++- .../napplethost/NappletHostService.kt | 3 ++- 18 files changed, 63 insertions(+), 29 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0d3293a869..7225a48c67 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -282,6 +282,26 @@ Do this before considering the task complete. - The only acceptable inline fully-qualified names are: a genuine name collision (prefer `import ... as Alias` instead), or where the language requires it. Comments, KDoc, and string literals are exempt. +- **Prefer the `androidx.core` KTX extension over the raw platform Java call** + when one exists — this is what Android Lint's `UseKtx` flags. Common swaps: + `Bitmap.createBitmap(w, h, cfg)` → `createBitmap(w, h)`, + `Bitmap.createScaledBitmap(src, w, h, f)` → `src.scale(w, h, f)`, + `Uri.parse(s)` → `s.toUri()`, and `prefs.edit()…apply()` → `prefs.edit { }`. + Only adopt the KTX form when it's behaviour-preserving: keep any explicit + argument that differs from the extension's default (a non-`ARGB_8888` + `Bitmap.Config`, `scale(filter = false)`), and leave calls the KTX has no + equivalent for (e.g. the `createBitmap` pixels/matrix overloads, or a + conditional-`apply()` editor loop) untouched. +- **This "prefer the KTX sugar" rule does NOT extend to collection operators.** + The KTX preference is about platform wrappers (`Bitmap`/`Uri`/`SharedPreferences`), + which compile to the identical call. Collections are the opposite: in hot + event/parse paths Quartz deliberately uses raw JVM arrays (`TagArray = + Array>`) and the inline `fast*` operators (`fastForEach`, + `fastAny`, `fastFirstOrNull`, `fastFirstNotNullOfOrNull`, … in + `nip01Core/core/TagArray.kt`) instead of Kotlin `List` + stdlib + `forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator, + no intermediate list, and no lambda object. Don't "modernize" those into + stdlib collection calls; match the surrounding hot-path style. ### Navigation Shell - **Desktop**: Sidebar + main content area diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index a5f447e654..84ebadec75 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst import android.graphics.Bitmap import android.graphics.Color +import androidx.core.graphics.createBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.vitorpamplona.amethyst.model.AccountSettings @@ -81,7 +82,7 @@ class ImageUploadTesting { .build() private fun getBitmap(): ByteArray { - val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(200, 300) for (x in 0 until bitmap.width) { for (y in 0 until bitmap.height) { bitmap.setPixel(x, y, Color.rgb(Random.nextInt(), Random.nextInt(), Random.nextInt())) diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt index 04f5e861e6..8e5c047041 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/service/images/ThumbnailDiskCacheInstrumentedTest.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.images import android.graphics.Bitmap +import androidx.core.graphics.createBitmap import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.After @@ -44,7 +45,7 @@ class ThumbnailDiskCacheInstrumentedTest { cacheDir = File(appContext.cacheDir, "thumbnail-test-${UUID.randomUUID()}") cache = ThumbnailDiskCache(cacheDir) sourceFile = File(appContext.cacheDir, "source-${UUID.randomUUID()}.jpg") - val bitmap = Bitmap.createBitmap(64, 64, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(64, 64) sourceFile.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it) } bitmap.recycle() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 59f276401d..c08d211060 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -32,6 +32,7 @@ import android.os.Messenger import android.os.RemoteException import android.os.SystemClock import android.util.Log +import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker @@ -359,7 +360,7 @@ class NappletBrokerService : Service() { val intent = Intent(applicationContext, MainActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)) + data = ("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)).toUri() } runCatching { applicationContext.startActivity(intent) } .onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt index 185b780e99..2038417d1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebAppNetworkRegistry.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context -import android.net.Uri +import androidx.core.net.toUri import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore @@ -86,7 +86,7 @@ object WebAppNetworkRegistry { } /** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */ - fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + fun hostKeyOf(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url /** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */ fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt index c496d512bd..5cc2d5a8fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderNotifier.kt @@ -27,6 +27,7 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes @@ -64,7 +65,7 @@ object CalendarReminderNotifier { val tapIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW - data = android.net.Uri.parse(deepLink) + data = deepLink.toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) } val tapPendingIntent = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt index f3dda3056f..737cffdadb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderPrefs.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit /** * Device-wide preferences for the calendar reminder worker. @@ -40,13 +41,13 @@ class CalendarReminderPrefs( fun isEnabled(): Boolean = prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED) fun setEnabled(enabled: Boolean) { - prefs.edit().putBoolean(KEY_ENABLED, enabled).apply() + prefs.edit { putBoolean(KEY_ENABLED, enabled) } } fun leadMinutes(): Int = prefs.getInt(KEY_LEAD_MINUTES, DEFAULT_LEAD_MINUTES) fun setLeadMinutes(minutes: Int) { - prefs.edit().putInt(KEY_LEAD_MINUTES, minutes).apply() + prefs.edit { putInt(KEY_LEAD_MINUTES, minutes) } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt index eb78d56322..81c3ad882f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderStore.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.calendar import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit /** * Persistent "I've already notified for this event" set. Backed by [SharedPreferences] because @@ -54,7 +55,7 @@ class CalendarReminderStore( eventId: String, eventStartSeconds: Long, ) { - prefs.edit().putLong(keyFor(eventId), eventStartSeconds).apply() + prefs.edit { putLong(keyFor(eventId), eventStartSeconds) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt index 8e9f422ccc..ba370ddfba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/location/MapPinIcon.kt @@ -25,6 +25,7 @@ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path +import androidx.core.graphics.createBitmap import kotlin.math.roundToInt /** @@ -57,7 +58,7 @@ fun roadEventPinBitmap( val cx = width / 2f val cy = pad + radius - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(width, height) val canvas = Canvas(bitmap) // Head circle + pointer drawn as a single path so they share one shadow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt index 639c26947c..54d4a4ab25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Ps1Save.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -46,6 +45,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.stringRes @@ -160,8 +160,7 @@ private fun Ps1SaveIconImage(icon: Ps1SaveIcon) { val frames = remember(icon) { icon.frames.map { pixels -> - Bitmap - .createBitmap(Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE, Bitmap.Config.ARGB_8888) + createBitmap(Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE) .apply { setPixels(pixels, 0, Ps1SaveIcon.SIZE, 0, 0, Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE) } .asImageBitmap() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 9b903b4b33..8265fa2eff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser -import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi @@ -44,6 +43,7 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -193,14 +193,14 @@ private fun EmbeddedWebAppTab( } /** The host of [url] for the tab title, falling back to the raw string. */ -internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url +internal fun hostLabel(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url /** * The `scheme://host[:port]` origin of [url] — the exact form the sandbox reports for NIP-07 consent, so * it matches the `browser:` permission-ledger key. Null when [url] has no usable scheme/host. */ internal fun browserOrigin(url: String): String? { - val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null + val uri = runCatching { url.toUri() }.getOrNull() ?: return null val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null val host = uri.host?.takeIf { it.isNotBlank() } ?: return null return "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index e25c76a2be..ad89bdc42b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -59,6 +59,7 @@ 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.core.net.toUri import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -582,11 +583,11 @@ private fun LocationRow(location: String) { val trimmed = location.trim() val intent = if (isUrl) { - Intent(Intent.ACTION_VIEW, Uri.parse(trimmed)) + Intent(Intent.ACTION_VIEW, trimmed.toUri()) } else { // `geo:0,0?q=` is the Android geo intent; the user's // installed maps app handles it. - Intent(Intent.ACTION_VIEW, Uri.parse("geo:0,0?q=${Uri.encode(trimmed)}")) + Intent(Intent.ACTION_VIEW, "geo:0,0?q=${Uri.encode(trimmed)}".toUri()) } // runCatching swallows ActivityNotFoundException when no handler is // installed — we don't have anywhere useful to fall back to. diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt index 3c494a6904..b8075bca4e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap +import androidx.core.graphics.scale actual class PlatformImage( val bitmap: Bitmap, @@ -43,7 +44,7 @@ actual class PlatformImage( actual fun scale( width: Int, height: Int, - ): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false)) + ): PlatformImage = PlatformImage(bitmap.scale(width, height, false)) actual companion object { actual fun create( diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 953525260e..bc603dacb0 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.keystorage import android.content.Context +import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import kotlinx.coroutines.Dispatchers @@ -86,7 +87,7 @@ actual class SecureKeyStorage private actual constructor() { ) { withContext(Dispatchers.IO) { try { - encryptedPrefs.edit().putString(KEY_PREFIX + npub, privKeyHex).apply() + encryptedPrefs.edit { putString(KEY_PREFIX + npub, privKeyHex) } } catch (e: Exception) { throw SecureStorageException("Failed to save private key", e) } @@ -108,7 +109,7 @@ actual class SecureKeyStorage private actual constructor() { val key = KEY_PREFIX + npub val existed = encryptedPrefs.contains(key) if (existed) { - encryptedPrefs.edit().remove(key).apply() + encryptedPrefs.edit { remove(key) } } existed } catch (e: Exception) { diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt index cba028d6a1..53e63f7e8e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessDismissedGamesStorage.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.nip64Chess import android.content.Context import android.content.SharedPreferences +import androidx.core.content.edit actual class ChessDismissedGamesStorage private actual constructor() { private var prefs: SharedPreferences? = null @@ -48,6 +49,6 @@ actual class ChessDismissedGamesStorage private actual constructor() { userPubkey: String, ids: Set, ) { - prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply() + prefs?.edit { putStringSet(prefsKey(userPubkey), ids) } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 9088b7f8b7..d7deb5b6f3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -54,6 +54,8 @@ import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.core.graphics.Insets +import androidx.core.graphics.scale +import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.webkit.JavaScriptReplyProxy @@ -456,7 +458,7 @@ class NappletBrowserActivity : ComponentActivity() { runCatching { val scaled = if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) { - Bitmap.createScaledBitmap(icon, ICON_MAX_PX, ICON_MAX_PX, true) + icon.scale(ICON_MAX_PX, ICON_MAX_PX) } else { icon } @@ -597,7 +599,7 @@ class NappletBrowserActivity : ComponentActivity() { // Key the persisted choice on the host actually displayed (which may differ from startUrl after // in-page navigation), so the preference sticks to the right site. val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val host = runCatching { Uri.parse(liveUrl).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return + val host = runCatching { liveUrl.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return val msg = Message.obtain(null, NappletIpc.MSG_SET_WEB_TOR).apply { data = @@ -613,7 +615,7 @@ class NappletBrowserActivity : ComponentActivity() { private var title: String = "" - private fun barTitle(): String = title.ifBlank { runCatching { Uri.parse(startUrl).host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } + private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } /** * The top pull-down sheet: a small grabber at the top edge (out of the corner where a site shows its @@ -644,7 +646,7 @@ class NappletBrowserActivity : ComponentActivity() { */ private fun openPermissions() { val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val uri = runCatching { Uri.parse(liveUrl) }.getOrNull() ?: return + val uri = runCatching { liveUrl.toUri() }.getOrNull() ?: return val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return val host = uri.host?.takeIf { it.isNotBlank() } ?: return val origin = "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" @@ -774,6 +776,6 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(EXTRA_THEME, theme) .putExtra(EXTRA_IS_FAVORITE, isFavorite) // Distinct task identity per URL for documentLaunchMode=intoExisting. - .setData(Uri.parse(url)) + .setData(url.toUri()) } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 43556385a0..9208f9d275 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -45,6 +45,7 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat @@ -206,7 +207,7 @@ class NappletBrowserService : Service() { val outW = (boxW * zoom).toInt().coerceAtLeast(1) val outH = (boxH * zoom).toInt().coerceAtLeast(1) val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(outW, outH) val canvas = Canvas(bitmap) canvas.drawColor(tab.bgColor) // Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index b2fe56b483..d038a40ec2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -45,6 +45,7 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig @@ -238,7 +239,7 @@ class NappletHostService : Service() { val outW = (boxW * zoom).toInt().coerceAtLeast(1) val outH = (boxH * zoom).toInt().coerceAtLeast(1) val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = Bitmap.createBitmap(outW, outH, Bitmap.Config.ARGB_8888) + val bitmap = createBitmap(outW, outH) val canvas = Canvas(bitmap) canvas.drawColor(tab.bgColor) canvas.scale(zoom, zoom) From f2f8bdb7655398dd254f1c5a2aa22ce08fdb67c3 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 16:13:04 +0000 Subject: [PATCH 073/206] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index a8a851778e..ce35da6323 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -803,16 +803,19 @@ क्या आपका सन्देश %1$s को भेजें। क्या %1$s को सूचित करें। + क्या %1$s से परिचय पत्र तथा गतिविधियों का आवहन करें। क्या %1$s को पत्र भेजें। क्या %1$s को खोलें। यदि आप पुष्टि नहीं करते तो %1$s को आपका सन्देश नहीं भेजा जाएगा। यदि आप नहीं स्वीकारते तो %1$s को इस विषय पर सूचित नहीं किया जाएगा। + यदि नहीं स्वीकारते तो %1$s से परिचय पत्र अथवा गतिविधियाँ आपको यहाँ नहीं दिखेगा। यदि आप स्वीकारते नहीं तो %1$s को आपका सन्देश प्रकाशित नहीं किया जाएगा। यदि आप स्वीकारते नहीं तो आप यहाँ %1$s को नहीं देखेंगे। %1$s तथा अन्य आपका निजी सन्देश भेजें इनको : इनको सूचित करें : + परिचय जानकारी पत्र तथा गतिविधियों का अवरोहण करें यहाँ से : इस शाला को पत्र भेजें इस शाला को खोलें इस पुनःप्रसारक का उपयोग करें @@ -3445,6 +3448,27 @@ लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में। क्या खनन करना चाहिए समयसंकट घटनाओं (पुनःप्रसारक प्रमाणीकरण ज्साप अनुरोध धनकोष तथा हस्ताक्षरकर्ता सन्देश पाण्डुलिपियाँ सूचियाँ) के लिए खनन कभी नहीं। + लघु टीकाएँ तथा प्रत्युत्तर + प्राथमिक सार्वजनिक कचरालेख रूप + टिप्पणियाँ + निबन्ध अभिलेख तथा अन्य विषयवस्तुओं के लिए प्रत्युत्तर + सूचनाएँ + पुनःप्रसारक सूचनाओं का तोलन करते हैं उनके लागत के अनुसार + दीर्घरूप तथा उद्दीप्तव्य + निबन्ध तथा उद्दीप्तव्य। विरल। लागत नगण्य + ध्वनि सन्देश + सार्वजनिक ध्वनि पत्र तथा प्रत्युत्तर + पुनःप्रकाशन + उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए + प्रतिक्रियाएँ + उच्चतम मात्रा प्रकार। प्रत्येक इष्टसूचक के लिए खनन का परिणाम विद्युत्कोष का व्यय + सार्वजनिक तथा सीधा चर्चा + खनन विलम्बता से वार्तालाप प्रवाह आहत + निजी सन्देश कोष + सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं + अन्य सार्वजनिक विषयवस्तु + मतदान वर्तमानस्थितियाँ वर्गीकृतविज्ञापन तथा अन्य सभी सार्वजनिक + श्रमप्रमाण खनन इसका प्रयोग करें हटाएँ सम्यक From 3feb0049e64a3386c7840154f8f7b88fa95e056c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 21:29:33 +0000 Subject: [PATCH 074/206] perf: reduce background and mobile-data battery usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five targeted fixes for the biggest idle drains: - NostrClient keep-alive sweep: the 60s reconnect loop kept ticking for the whole life of the process even after disconnect() (i.e. the entire time the app sat in the background). isActive is now a StateFlow and the loop suspends on it, so an inactive client schedules zero timers. - Relay WebSocket pings on cellular: every ping on an idle connection promotes the radio out of its low-power state, per connection. The mobile-data client now pings every 240s instead of 120s (wifi keeps 120s), staying under common carrier NAT idle timeouts. - Always-on service watchdog: the 5-min health-check alarm no longer uses the _WAKEUP variant. Pulling the CPU out of sleep to restart a service that can't do useful network work on a sleeping device was pure cost; the alarm now fires as soon as the device is next awake, and the WorkManager + FCM/UnifiedPush layers still cover doze. - ScheduledPostWorker: the 15-min periodic worker was enqueued forever for every user, waking (often cold-starting) the process with nothing to publish. It is now enqueued exactly while the store holds a PENDING post, driven by a store-flow observer in AppModules. - CalendarReminderWorker: same unconditional 15-min periodic schedule, but worse — LocalCache is memory-only, so a WorkManager wake of a dead process can never find an RSVP to remind about. The worker is now scheduled when an ACCEPTED RSVP lands in LocalCache and cancels its own chain when nothing upcoming remains (or the feature is disabled). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- PULL_NOTIFICATION.md | 10 +++- .../com/vitorpamplona/amethyst/AppModules.kt | 56 +++++++++++++++---- .../calendar/CalendarReminderWorker.kt | 33 ++++++++++- .../notifications/ServiceWatchdogManager.kt | 7 ++- .../okhttp/OkHttpClientFactoryForRelays.kt | 23 +++++++- .../scheduledposts/ScheduledPostWorker.kt | 23 ++++++-- .../CalendarReminderSettingsScreen.kt | 13 +++-- .../nip01Core/relay/client/NostrClient.kt | 35 ++++++++---- 8 files changed, 163 insertions(+), 37 deletions(-) diff --git a/PULL_NOTIFICATION.md b/PULL_NOTIFICATION.md index ef71d19e2a..bd407f484b 100644 --- a/PULL_NOTIFICATION.md +++ b/PULL_NOTIFICATION.md @@ -158,13 +158,17 @@ Google Play Services infrastructure. ### Layer 4: AlarmManager Watchdog (5 minutes) -**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5 +**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME` alarm every 5 minutes. The receiver checks if the service should be running and restarts it. **Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail (sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't -delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to -wake the device from sleep, ensuring the check happens even in Doze. +delivered), the watchdog will catch it within 5 minutes of the device being awake. +The alarm deliberately does NOT use the `_WAKEUP` variant: pulling the CPU out of +sleep every 5 minutes is a battery cost with no payoff, because a service restarted +on a sleeping device can't do useful network work until the device wakes anyway. +While the device sleeps, Layer 5 (WorkManager) and Layer 8 (FCM/UnifiedPush) cover +delivery; the moment the device wakes, the pending watchdog alarm fires. ### Layer 5: WorkManager Periodic Catch-Up (15 minutes) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..fbdd4b444e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus @@ -86,6 +87,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.safeCacheDir +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver @@ -105,6 +107,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDnsStore import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache @@ -124,6 +127,8 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend @@ -887,17 +892,48 @@ class AppModules( // starts observing LocalCache for notification-worthy events notificationDispatcher.start() - // Schedule the scheduled-posts worker (periodic + one-time catch-up). - // Runs independently of the always-on notification setting so scheduled - // posts still fire when always-on notifications are disabled. - ScheduledPostWorker.schedule(appContext) - ScheduledPostWorker.scheduleCatchUp(appContext) + // Keep the scheduled-posts worker (15-min periodic + one-time catch-up) + // enqueued exactly while the store holds a PENDING post. An always-on + // periodic worker wakes — and often cold-starts — the whole process every + // 15 minutes forever, even for users who never schedule a post. The store + // is durable (JSON on disk) and the single source of truth, so the worker + // is (re-)enqueued from any mutation that produces a PENDING row and + // cancelled when the last one drains. Runs independently of the always-on + // notification setting so scheduled posts still fire when always-on + // notifications are disabled. + applicationIOScope.launch { + // Force the initial disk load; the flow's initial value is an empty + // list until the store is first touched. + scheduledPostStore.list() + scheduledPostStore.flow + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .distinctUntilChanged() + .collect { hasPending -> + if (hasPending) { + ScheduledPostWorker.schedule(appContext) + ScheduledPostWorker.scheduleCatchUp(appContext) + } else { + ScheduledPostWorker.cancelPeriodic(appContext) + } + } + } - // Periodic scan that posts "starting soon" notifications for NIP-52 appointments the - // user has RSVP'd to as ACCEPTED. 15-minute cadence matches both the WorkManager - // periodic minimum and the lead-time window. - com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker - .schedule(appContext) + // "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as + // ACCEPTED. The 15-min periodic scanner is only scheduled while it can + // plausibly fire: this observer enqueues it when an accepted RSVP lands in + // LocalCache, and the worker cancels its own chain when the cache holds + // nothing that could still start. LocalCache is memory-only, so the + // unconditional schedule this replaces could never fire from a WorkManager + // cold start anyway — it only cost battery. + applicationIOScope.launch { + LocalCache + .observeNewEvents(Filter(kinds = listOf(CalendarRSVPEvent.KIND))) + .collect { rsvp -> + if (rsvp.status() == RSVPStatusTag.STATUS.ACCEPTED) { + CalendarReminderWorker.schedule(appContext) + } + } + } // Watch for account login and start/stop always-on notification service applicationIOScope.launch { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 0cb0b6f217..4560fe86e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -47,6 +47,13 @@ import java.util.concurrent.TimeUnit * consults [CalendarReminderStore] to skip events that have already been notified for. Run as * a 15-minute periodic worker: that's the WorkManager minimum and matches the resolution of * the reminder UI ("starts in ~15 min" is the smallest interval users perceive as "soon"). + * + * The periodic chain is only kept alive while it can plausibly fire: the ACCEPTED-RSVP + * observer in AppModules calls [schedule] when an accepted RSVP lands in LocalCache, and + * [doWork] cancels the chain when the cache holds no accepted RSVP that could still start. + * LocalCache is memory-only, so a WorkManager wake of a dead process always sees an empty + * cache and can never fire a reminder — an unconditional periodic schedule would cold-start + * the whole app graph every 15 minutes forever for zero benefit. */ class CalendarReminderWorker( appContext: Context, @@ -55,7 +62,10 @@ class CalendarReminderWorker( override suspend fun doWork(): Result { val prefs = CalendarReminderPrefs(applicationContext) if (!prefs.isEnabled()) { - Log.d(TAG) { "Reminders disabled; skipping scan." } + Log.d(TAG) { "Reminders disabled; ending periodic chain." } + // The settings toggle re-schedules on enable; no reason to keep + // waking the process while the feature is off. + cancel(applicationContext) return Result.success() } val now = TimeUtils.now() @@ -110,6 +120,27 @@ class CalendarReminderWorker( // Prune entries for events that ended more than a day ago — they can't fire again. store.forgetBefore(now - PRUNE_AGE_SECONDS) + + // Nothing left that could ever fire → end the periodic chain instead of + // waking the process every 15 minutes forever. An RSVP whose target event + // hasn't been fetched yet (start unknown) counts as "could still fire" so + // the chain survives until the target resolves. The ACCEPTED-RSVP observer + // in AppModules re-schedules the worker the next time a live session sees + // an accepted RSVP. + val couldStillFire = + acceptedRsvps.any { rsvp -> + val targetAddress = rsvp.calendarEventAddress() ?: return@any false + val start = + LocalCache.addressables + .get(targetAddress) + ?.appointmentView() + ?.startSeconds + start == null || start > now + } + if (!couldStillFire) { + Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } + cancel(applicationContext) + } return Result.success() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt index dc9fd2ccdc..1d2b317928 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt @@ -52,8 +52,13 @@ class ServiceWatchdogManager { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + // ELAPSED_REALTIME (not _WAKEUP): a health check is not worth pulling + // the CPU out of sleep — if the device is asleep, a restarted service + // couldn't do useful network work anyway. The alarm fires as soon as + // the device is next awake, and the deeper restart layers (15-min + // WorkManager job, FCM/UnifiedPush) cover the force-stop/doze cases. alarmManager.setInexactRepeating( - AlarmManager.ELAPSED_REALTIME_WAKEUP, + AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, WATCHDOG_INTERVAL_MS, pendingIntent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index fbce710e6f..1f826aad05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -40,7 +40,16 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_IS_MOBILE: Boolean = false const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 - const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 + + // Every WebSocket ping on an otherwise-idle cellular connection promotes + // the radio out of its low-power state and pays the multi-second "tail" + // energy cost — per connection. On mobile data the interval is doubled to + // halve those wake-ups while staying under the ~5-minute idle timeout of + // the most aggressive carrier NATs (so connections aren't silently dropped + // between pings). Wifi keeps the tighter interval: pings there are cheap + // and detect dead connections sooner. + const val WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS: Long = 120 + const val WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS: Long = 240 } val myDispatcher = @@ -78,6 +87,7 @@ class OkHttpClientFactoryForRelays( fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, + pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS, ): OkHttpClient { if (proxy != lastProxy) { rootClient.connectionPool.evictAll() @@ -91,7 +101,7 @@ class OkHttpClientFactoryForRelays( .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) - .pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) + .pingInterval(Duration.ofSeconds(pingIntervalSeconds)) .build() } @@ -102,12 +112,14 @@ class OkHttpClientFactoryForRelays( buildHttpClient( buildLocalSocksProxy(localSocksProxyPort), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), + buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildHttpClient(isMobile: Boolean?): OkHttpClient = buildHttpClient( null, buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), + buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildTimeout(isMobile: Boolean): Int = @@ -117,5 +129,12 @@ class OkHttpClientFactoryForRelays( DEFAULT_TIMEOUT_ON_WIFI_SECS } + fun buildPingInterval(isMobile: Boolean): Long = + if (isMobile) { + WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS + } else { + WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS + } + fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index b9b339332f..6663d98463 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -42,10 +42,15 @@ import java.util.concurrent.TimeUnit /** * Scans the scheduled-post store and publishes posts whose publish time has arrived. * - * - schedule(context): periodic, every 15 min (WorkManager minimum). - * - scheduleCatchUp(context): one-time, on app start, to flush posts that came - * due while the device was off or while WorkManager - * was deferred by Doze. + * - schedule(context): periodic, every 15 min (WorkManager minimum). Only + * enqueued while the store holds a PENDING post — the + * store observer in AppModules schedules it when a + * pending post appears and cancels it when the last + * one drains, so the worker never wakes the process + * with nothing to publish. + * - scheduleCatchUp(context): one-time, to flush posts that came due while the + * device was off or while WorkManager was deferred + * by Doze. */ class ScheduledPostWorker( appContext: Context, @@ -107,6 +112,16 @@ class ScheduledPostWorker( Log.d(TAG) { "scheduleCatchUp(): enqueueUniqueWork($WORK_NAME_CATCH_UP, KEEP)" } } + /** + * Ends the 15-minute periodic chain (keeps any catch-up run). Called by the + * store observer in AppModules when the last PENDING post drains, so the + * worker doesn't keep waking the process with nothing to publish. + */ + fun cancelPeriodic(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + Log.d(TAG) { "cancelPeriodic(): cancelled periodic worker" } + } + fun cancel(context: Context) { WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_CATCH_UP) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt index 8299ce35d4..6959718e23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/CalendarReminderSettingsScreen.kt @@ -105,11 +105,14 @@ fun CalendarReminderSettingsScreen(nav: INav) { onCheckedChange = { enabled = it prefs.setEnabled(it) - // Toggling off doesn't cancel the worker — the worker itself short- - // circuits when isEnabled() returns false. Keeping the schedule alive - // means flipping it back on takes effect immediately without needing a - // re-launch via AppModules. - if (it) CalendarReminderWorker.schedule(context) + // Cancel eagerly on disable so the periodic worker stops waking the + // process; re-enabling re-schedules immediately, and the ACCEPTED-RSVP + // observer in AppModules re-schedules on the next relevant RSVP too. + if (it) { + CalendarReminderWorker.schedule(context) + } else { + CalendarReminderWorker.cancel(context) + } }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt index 98d8f6a377..2e98749f34 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClient.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn @@ -107,7 +108,12 @@ class NostrClient( // new filters will be sent to the relays and a potential reconnect can // be triggered. // Default: STARTS active - private var isActive = true + // + // A StateFlow (not a plain Boolean) so the keep-alive loop below can + // suspend without any scheduled timer while the client is inactive — + // e.g. the whole time the app sits in the background after the host + // calls [disconnect]. + private val isActiveFlow = MutableStateFlow(true) /** * Whatches for any changes in the relay list from subscriptions or outbox @@ -132,16 +138,16 @@ class NostrClient( // Reconnects all relays that may have disconnected override fun connect() { - isActive = true + isActiveFlow.value = true relayPool.connect() } override fun disconnect() { - isActive = false + isActiveFlow.value = false relayPool.disconnect() } - override fun isActive() = isActive + override fun isActive() = isActiveFlow.value class Reconnect( val onlyIfChanged: Boolean, @@ -173,12 +179,19 @@ class NostrClient( * error code) would stay disconnected forever in the absence of any * subscription change. The per-relay [BasicRelayClient] backoff still * gates the actual reconnect attempt, so dead relays are not hammered. + * + * While the client is inactive (the host called [disconnect], e.g. the + * app moved to the background) the loop suspends on [isActiveFlow] + * instead of ticking: no timer fires at all until [connect] flips the + * flag back, saving periodic CPU wake-ups for the whole background + * stretch. */ private val keepAliveJob = scope.launch { while (true) { + isActiveFlow.first { it } delay(KEEP_ALIVE_INTERVAL_MS) - if (this@NostrClient.isActive) { + if (this@NostrClient.isActive()) { relayPool.reconnectIfNeedsTo(ignoreRetryDelays = false) } } @@ -202,7 +215,7 @@ class NostrClient( ) { val relaysToUpdate = activeRequests.addOrUpdate(subId, filters, listener) - if (isActive) { + if (isActive()) { activeRequests.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> if (cmd is CloseCmd || cmd is AuthCmd) { relayPool.sendIfConnected(relay, cmd) @@ -225,7 +238,7 @@ class NostrClient( ) { val relaysToUpdate = activeCounts.addOrUpdate(subId, filters) - if (isActive) { + if (isActive()) { activeCounts.sendToRelayIfChanged(subId, relaysToUpdate) { relay, cmd -> if (cmd is CloseCmd || cmd is AuthCmd) { relayPool.sendIfConnected(relay, cmd) @@ -245,7 +258,7 @@ class NostrClient( ) { val relaysToUpdate = eventOutbox.markAsSending(event, relayList) - if (isActive) { + if (isActive()) { eventOutbox.sendToRelayIfChanged(event, relaysToUpdate, relayPool::sendOrConnectAndSync) // wakes up all the other relays @@ -257,14 +270,14 @@ class NostrClient( val relaysToUpdateReqs = activeRequests.remove(subId) val relaysToUpdateCounts = activeCounts.remove(subId) - if (isActive) { + if (isActive()) { activeRequests.sendToRelayIfChanged(subId, relaysToUpdateReqs, relayPool::sendIfConnected) activeCounts.sendToRelayIfChanged(subId, relaysToUpdateCounts, relayPool::sendIfConnected) } } override fun syncFilters(relay: IRelayClient) { - if (isActive) { + if (isActive()) { scope.launch { activeRequests.syncState(relay.url, relay::sendOrConnectAndSync) activeCounts.syncState(relay.url, relay::sendOrConnectAndSync) @@ -337,7 +350,7 @@ class NostrClient( // The reconnect path is debounced and goes through // reconnectIfNeedsTo, which respects each relay's exponential // backoff so we don't hammer dead relays. - if (isActive && relay.url in allRelays.value) { + if (isActive() && relay.url in allRelays.value) { reconnect(onlyIfChanged = true) } } From 82f1780b551abd705e7108f294caaf01fa1ec4cb Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:01:21 +0000 Subject: [PATCH 075/206] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index ce35da6323..af4c9b8139 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -663,7 +663,7 @@ पसंदीदा से हटाएँ अपनी विंडो में खोलें इस क्रमक के लिए क्या अभिगम्य है - पृथक पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं। + पृथक्कृत पर्यावरण में चलता है। आपके लेखा तक कोई विशेष अभिगमन अधिकार नहीं। इसके लिए क्या अभिगम्य है Tor पर लोड होता है। खुले वेब पर लोड होता है। @@ -799,7 +799,7 @@ किसी को भी सन्देश भेजें अज्ञात लोगों के पुनःप्रसारकों में प्रवेशांकन करें सीधेसन्देश तथा प्रत्युत्तर तथा सूचनाएँ भेजने के लिए। मूलविकल्पतः निष्क्रिय। प्रत्येक बार आप से पूछा जाएगा। क्या इस पुनःप्रसारक को पुष्टि करें कि यह आप हैं। - यह पुनःप्रसारक पहले पुष्टि करना चाहता हैं कि यह वस्तुतः आप हैं। इसके परिचालक देखेगा आपका लेखा कौनसा है। + यह पुनःप्रसारक पहले पुष्टि करना चाहता है कि यह वस्तुतः आप हैं। इसका परिचालक देखेगा आपका लेखा कौनसा है। क्या आपका सन्देश %1$s को भेजें। क्या %1$s को सूचित करें। @@ -1795,7 +1795,7 @@ इस अक्षरराशि को बाँटें जिससे लोग इस समूह से जुड सकें। आमन्त्रण बनाया जा रहा है… आमन्त्रण अक्षरराशि अनुकृत - आमन्त्रण अक्षरराशि (इस केवल आमन्त्रित समूह के लिए): + आमन्त्रण अक्षरराशि (इस आमन्त्रण सीमित समूह के लिए): समूह से जुडें आमन्त्रण अक्षरराशि यह समूह आमन्त्रण सीमित है। आपको दी गई अक्षरराशि प्रविष्ट करें। @@ -3306,7 +3306,7 @@ %1$s सं॰ %2$d अधिक - पीएस१ स्मृति पट्टि अभिलेखन + पीएस१ स्मृति पत्रक अभिलेखन खण्ड %1$d रिक्त छेद @@ -3440,9 +3440,9 @@ हस्ताक्षर सन्देश के अन्त में जोडा जाएगा नए पत्र प्रत्युत्तर उद्धरण अथवा निबन्ध खोलते समय। रिक्त छोड दें अक्षम करने के लिए। आपका हस्ताक्षर - श्रम प्रमाण + श्रमप्रमाण कठिनाई - एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीय अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। + एक निप॰१३ श्रमप्रमाण का खनन करता है आपके पत्रों के लिए प्रकाशन पूर्व जिससे पुनःप्रसारक तथा पढनेवाले उसका तोलन कर सके कचरालेख के प्रति। उच्चतर मूल्यों के लिए घातांकीयतः अधिक समय लगेगा। पत्र प्रकाशित होंगे अदृश्यतः खनन पश्चात। निष्क्रिय विशिष्ट कठिनाई लक्ष्य का सूक्ष्मानुकूलन अग्रतम शून्य द्व्यंकों में। @@ -3469,6 +3469,55 @@ अन्य सार्वजनिक विषयवस्तु मतदान वर्तमानस्थितियाँ वर्गीकृतविज्ञापन तथा अन्य सभी सार्वजनिक श्रमप्रमाण खनन + + श्रमप्रमाण खनन… (%1$d पत्र पंक्ति में) + श्रमप्रमाण खनन… (%1$d पत्र पंक्ति में) + + + %1$s। खनन %2$d द्व्यंक पर + %1$s। खनन %2$d द्व्यंक पर + + + %1$s। %2$d द्व्यंक पर खनन की प्रतीक्षा + %1$s। %2$d द्व्यंक पर खनन की प्रतीक्षा + + + मूलविकल्प (%1$d द्व्यंक) + मूलविकल्प (%1$d द्व्यंक) + + मूलविकल्प (निष्क्रिय) + इस पत्र के लिए निष्क्रिय + + %1$d द्व्यंक + %1$d द्व्यंक + + श्रमप्रमाण खनन + उन पत्रों को दिखाता है जो अपने निप॰१३ श्रमप्रमाण का खनन कर रहे हैं जिससे कि वे सम्पन्न हो सके आप क्रमक छोडने के पश्चात। + निरस्त + लगभग %1$s प्रति पत्र इस यन्त्र पर + १ से॰ से न्यून + + %1$d सेकण्ड + %1$d सेकण्ड + + + %1$d मिनुट + %1$d मिनुट + + + %1$d घण्टा + %1$d घण्टे + + + %1$d दिन + %1$d दिन + + लगभग %1$s शेष + अब किसी भी क्षण + आपका %1$s खनन समाप्त हुआ पर प्रकाशित नहीं हो सका : %2$s + आपका %1$s खनन समाप्त हुआ पर प्रकाशित नहीं हो सका। अगली बार क्रमक आरम्भ होने पर इसका पुनःप्रयास किया जाएगा। + चर्चा सन्देश + सूचना इसका प्रयोग करें हटाएँ सम्यक From 60475c10c04d0f049b05cf95b55e268d6300eef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 01:21:58 +0000 Subject: [PATCH 076/206] =?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 077/206] 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 97c7fbfe80f2a207d054a06f34e447fb5bc241e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 02:59:08 +0000 Subject: [PATCH 078/206] fix: compact git-card action buttons and dedupe issue/PR subject heading The NIP-34 status buttons (Mark merged / Close / Reopen) and the PR View-changes button used default Material3 sizing, which reads oversized inside a note card. They now use the same 32dp compact height, tighter content padding and labelMedium text as the app-recommendation buttons. Issue and PR cards also showed the title twice when the authoring client duplicated the subject tag as a leading markdown heading in the content (gitworkshop/ngit do this). The body renderer now strips that first heading when it matches the subject already rendered as the card title. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy --- .../amethyst/ui/note/types/Git.kt | 29 ++++++++++++++--- .../ui/note/types/GitPullRequestChanges.kt | 20 ++++++++---- .../ui/note/types/GitStatusActions.kt | 32 ++++++++++++++----- 3 files changed, 63 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 99fb0abf00..218f677add 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -261,12 +261,31 @@ private fun GitMetaRow( /** Shortens a 40-char git object id to its 7-char prefix for display. */ private fun String.shortCommit(): String = if (length > 7) take(7) else this +/** + * Some NIP-34 clients duplicate the subject tag as a leading markdown heading of the + * content ("# Title\n\n…"). The card already renders the subject as its own title, so + * when the first line is a heading matching [subject], drop it from the body. + */ +private fun stripDuplicatedSubject( + content: String, + subject: String?, +): String { + if (subject.isNullOrBlank()) return content + val trimmed = content.trimStart() + if (!trimmed.startsWith("#")) return content + val firstLineEnd = trimmed.indexOf('\n').let { if (it < 0) trimmed.length else it } + val heading = trimmed.substring(0, firstLineEnd).trimStart('#').trim() + if (!heading.equals(subject.trim(), ignoreCase = true)) return content + return trimmed.substring(firstLineEnd).trimStart() +} + /** * The shared markdown body used by patches, issues and pull requests: honors * the collapsed [makeItShort] preview for the logged-in user's own posts and * otherwise renders the full content with sensitivity warnings and uncited * hashtags. The subject, when present, is rendered separately as a title by the - * caller, so it is not inlined here. + * caller, so it is not inlined here — pass it as [renderedSubject] so a copy + * duplicated into the content as a leading heading is stripped. */ @Composable private fun GitMarkdownBody( @@ -277,8 +296,10 @@ private fun GitMarkdownBody( backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, + renderedSubject: String? = null, ) { - LoadDecryptedContent(note, accountViewModel) { body -> + LoadDecryptedContent(note, accountViewModel) { rawBody -> + val body = remember(rawBody, renderedSubject) { stripDuplicatedSubject(rawBody, renderedSubject) } val isAuthorTheLoggedUser = remember(note.event) { accountViewModel.isLoggedUser(note.author) } @@ -537,7 +558,7 @@ private fun RenderGitIssueEvent( Spacer(modifier = HalfDoubleVertSpacer) - GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject) if (!makeItShort) { GitStatusActions(note, accountViewModel) @@ -669,7 +690,7 @@ private fun RenderGitPullRequestEvent( Spacer(modifier = HalfDoubleVertSpacer) - GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + GitMarkdownBody(note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav, renderedSubject = subject) if (!makeItShort) { GitPullRequestChanges(cloneUrls, currentCommit, mergeBase, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt index 6146391da6..71148c1c5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitPullRequestChanges.kt @@ -99,9 +99,13 @@ fun GitPullRequestChanges( when (val s = state) { ChangesState.Idle -> - FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { - Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_pr_view_changes), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { load() }, + modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight), + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Code, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_pr_view_changes), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } ChangesState.Loading -> @@ -129,9 +133,13 @@ fun GitPullRequestChanges( } ChangesState.Failed -> - FilledTonalButton(onClick = { load() }, modifier = Modifier.padding(top = 8.dp)) { - Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_pr_changes_retry), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { load() }, + modifier = Modifier.padding(top = 8.dp).then(CompactButtonHeight), + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_pr_changes_retry), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt index ea8cc7075a..5afc90e1e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/GitStatusActions.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ButtonDefaults @@ -55,6 +57,10 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent private enum class StatusTarget { OPEN, CLOSED, APPLIED } +/** Compact sizing shared by the small action buttons on git cards. */ +internal val CompactButtonHeight = Modifier.height(32.dp) +internal val CompactButtonPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp) + /** * NIP-34 status controls for an issue, patch or pull request. Visible only to the * people allowed to moderate the thread — the item's author and the repository @@ -85,23 +91,33 @@ fun GitStatusActions( verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (closedOrApplied) { - FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }) { - Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_reopen), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { sendStatus(accountViewModel, note, StatusTarget.OPEN) }, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.RadioButtonChecked, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_reopen), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } else { if (isPatchOrPr) { - FilledTonalButton(onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }) { - Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_mark_merged), modifier = Modifier.padding(start = 6.dp)) + FilledTonalButton( + onClick = { sendStatus(accountViewModel, note, StatusTarget.APPLIED) }, + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, + ) { + Icon(MaterialSymbols.Check, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_mark_merged), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } OutlinedButton( onClick = { sendStatus(accountViewModel, note, StatusTarget.CLOSED) }, colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = CompactButtonHeight, + contentPadding = CompactButtonPadding, ) { - Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(18.dp)) - Text(stringRes(R.string.git_status_close), modifier = Modifier.padding(start = 6.dp)) + Icon(MaterialSymbols.Cancel, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(stringRes(R.string.git_status_close), style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp)) } } } From e9899f95cbb309538e2352b80ee701557124b004 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:20:55 +0000 Subject: [PATCH 079/206] feat: remove border from git event cards Try git patch/issue/PR/repo cards without the 1dp outline so they read like regular posts; the container keeps only the full-width column. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01USiovhenaijgVEFFzASgfy --- .../vitorpamplona/amethyst/ui/note/types/Git.kt | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt index 218f677add..db61b42ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Git.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.amethyst.ui.note.types import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -85,7 +83,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.gitRepo.repoHasFetchableClo import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size5dp @@ -93,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size8dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent @@ -102,9 +98,7 @@ import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent -private val CardShape = QuoteBorder private val ChipShape = RoundedCornerShape(8.dp) -private val CardPadding = PaddingValues(start = Size10dp, top = Size10dp, end = Size10dp, bottom = Size5dp) private val HeaderSpacing = Arrangement.spacedBy(Size8dp) private val LinkRowSpacing = Arrangement.spacedBy(Size8dp) @@ -113,15 +107,7 @@ private fun GitCardContainer( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val border = MaterialTheme.colorScheme.subtleBorder - Column( - modifier = - modifier - .fillMaxWidth() - .clip(CardShape) - .border(1.dp, border, CardShape) - .padding(CardPadding), - ) { + Column(modifier = modifier.fillMaxWidth()) { content() } } From 0bac5c7aca52a8b2dfd02d1b6d1c5df84f0d79bc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 15:33:58 +0000 Subject: [PATCH 080/206] 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 d7e163111aad51229c293e7a006ab6a92bf9f386 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 12 Jul 2026 16:52:29 +0100 Subject: [PATCH 081/206] build: remove Kover coverage plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the Kover coverage aggregation (introduced in dca345212b) from every module, the root aggregation block, the Sonar coverage import, the version catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed during the Android test+build job even with Kover opt-in disabled; removing it entirely clears the OOM. No other CI changes — the diff versus main is exactly the inverse of dca345212b. Co-Authored-By: Claude Opus 4.8 (1M context) --- BUILDING.md | 19 ------------- amethyst/build.gradle.kts | 1 - build.gradle.kts | 53 ------------------------------------ cli/build.gradle.kts | 1 - commons/build.gradle.kts | 1 - desktopApp/build.gradle.kts | 1 - geode/build.gradle.kts | 1 - gradle/libs.versions.toml | 2 -- nestsClient/build.gradle.kts | 1 - quartz/build.gradle.kts | 1 - quic/build.gradle.kts | 1 - relayBench/build.gradle.kts | 1 - 12 files changed, 83 deletions(-) diff --git a/BUILDING.md b/BUILDING.md index 06dec12b15..54b6021e97 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -278,25 +278,6 @@ call does not guarantee lint finishes first): The reports persist under each module's `build/reports/`, so re-run lint only when you want fresh lint data in the next scan. -### 5. Optional: include test coverage - -Coverage comes from [Kover](https://github.com/Kotlin/kotlinx-kover). The root -`koverXmlReportCoverage` task runs the unit-test suites of the covered modules -(`quartz`, `commons`, `quic`, `nestsClient`, `geode`, `cli`, `relayBench`, -`desktopApp`, and `amethyst`'s `fdroidDebug` variant) and merges the result -into `build/reports/kover/reportCoverage.xml`, which the scanner imports -automatically when it exists. Same recipe as lint — generate first, scan -second: - -```bash -./gradlew koverXmlReportCoverage -./gradlew sonar -``` - -For a human-readable version of the same data, run -`./gradlew koverHtmlReportCoverage` and open -`build/reports/kover/htmlCoverage/index.html`. - Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any [analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) can be set there. `sonar.projectKey` / `sonar.projectName` default to the root diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index d7d65cc0cb..e4df9eb8bf 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) - alias(libs.plugins.kotlinxKover) } fun getCurrentBranch(workingDir: java.io.File): String = diff --git a/build.gradle.kts b/build.gradle.kts index 5d6326492d..6b87fbd049 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,50 +45,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) alias(libs.plugins.googleKsp) apply false - alias(libs.plugins.kotlinxKover) -} - -// Aggregated code coverage: `./gradlew koverXmlReportCoverage` runs the unit -// test suites of the modules below and merges their coverage into -// build/reports/kover/reportCoverage.xml (JaCoCo-compatible XML, importable by -// SonarQube — see BUILDING.md). `koverHtmlReportCoverage` renders the same -// data for humans. A named variant is used instead of Kover's default total -// report because the total for an Android project unconditionally merges -// EVERY build variant — compiling all release/benchmark variants of -// :amethyst just to measure unit tests. The "coverage" variant below picks -// one representative compilation per project: the `jvm` target for KMP/JVM -// modules and `playDebug` for :amethyst — play is the primary flavor and the -// larger code surface (both `add`s are optional so the one block fits every -// project). :nappletHost stays out — it has no tests. -dependencies { - kover(project(":amethyst")) - kover(project(":quartz")) - kover(project(":commons")) - kover(project(":quic")) - kover(project(":nestsClient")) - kover(project(":geode")) - kover(project(":cli")) - kover(project(":relayBench")) - kover(project(":desktopApp")) -} - -kover { - currentProject { - createVariant("coverage") {} - } -} - -subprojects { - plugins.withId("org.jetbrains.kotlinx.kover") { - configure { - currentProject { - createVariant("coverage") { - add("jvm", optional = true) - add("playDebug", optional = true) - } - } - } - } } // Shared app version for all subprojects — read from gradle/libs.versions.toml. @@ -164,15 +120,6 @@ if (sonarEnabled) { .filter { it.startsWith("sonar.") } .forEach { System.setProperty(it, sonarProperties.getProperty(it)) } - // Import Kover's aggregated coverage (run `./gradlew koverXmlReportCoverage` - // first) unless the operator pointed the scanner somewhere else. - if (System.getProperty("sonar.coverage.jacoco.xmlReportPaths") == null) { - System.setProperty( - "sonar.coverage.jacoco.xmlReportPaths", - layout.buildDirectory.file("reports/kover/reportCoverage.xml").get().asFile.absolutePath, - ) - } - // The scanner's sonarResolver task reads AGP's generated-res-values provider // but doesn't depend on the task that produces it — wire it up in every // module that has both (today only :amethyst enables resValues, but the diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 7d26b235b1..489e604740 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index b71358533c..8182a3604b 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -25,7 +25,6 @@ plugins { alias(libs.plugins.jetbrainsComposeCompiler) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.serialization) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index f49cad7ba2..10ca2b3d48 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,7 +6,6 @@ plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.jetbrainsComposeCompiler) - alias(libs.plugins.kotlinxKover) } // RPM rejects dashes in version strings — replace with tilde (~) which RPM uses diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 46e9f1cf23..ee636965c7 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.serialization) application `java-test-fixtures` - alias(libs.plugins.kotlinxKover) } application { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cc1cf2bad1..3d768731a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,6 @@ kmpTorResource = "409.5.0" junit = "4.13.2" kchesslib = "1.0.5" kotlin = "2.4.0" -kover = "0.9.8" kotlinxCollectionsImmutable = "0.5.0" kotlinxCoroutinesCore = "1.11.0" kotlinxSerialization = "1.11.0" @@ -247,7 +246,6 @@ jetbrainsKotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref = 'kotlin' } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinxKover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/nestsClient/build.gradle.kts b/nestsClient/build.gradle.kts index bb5acd43a2..3cb05ae465 100644 --- a/nestsClient/build.gradle.kts +++ b/nestsClient/build.gradle.kts @@ -5,7 +5,6 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 108587341f..10c0817ee8 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -9,7 +9,6 @@ plugins { alias(libs.plugins.androidKotlinMultiplatformLibrary) alias(libs.plugins.serialization) alias(libs.plugins.vanniktech.mavenPublish) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/quic/build.gradle.kts b/quic/build.gradle.kts index 55c28f88d1..def6dad697 100644 --- a/quic/build.gradle.kts +++ b/quic/build.gradle.kts @@ -23,7 +23,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidKotlinMultiplatformLibrary) - alias(libs.plugins.kotlinxKover) } kotlin { diff --git a/relayBench/build.gradle.kts b/relayBench/build.gradle.kts index 831f524883..8a02cc1e96 100644 --- a/relayBench/build.gradle.kts +++ b/relayBench/build.gradle.kts @@ -3,7 +3,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.jetbrainsKotlinJvm) application - alias(libs.plugins.kotlinxKover) } application { From 4212f38219010ab0ac838815d67d0cd39a5d3711 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 16:12:51 +0000 Subject: [PATCH 082/206] =?UTF-8?q?revert(relays):=20keep=20the=20120s=20W?= =?UTF-8?q?ebSocket=20ping=20on=20mobile=20=E2=80=94=20measured,=20not=20g?= =?UTF-8?q?uessed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed 122 production relays (top of 1,468 harvested from NIP-65 lists) with idle connections and client pings at 55/110/120/180/240/300s. Findings, detailed in amethyst/plans/2026-07-12-relay-ping-interval-study.md: - 99/122 relays survive 13 min of total client-ping silence; 90 of those send their OWN pings every 30-70s, which OkHttp must answer — so the cellular radio's wake cadence is set by the relays, not by our ping interval. The battery saving of the 240s mobile interval was illusory. - Idle-timeout tiers exist at ~60/~120/~240/~300/~600s, and pings only reliably reset a relay's timer when the interval is well below the tier: 240s pings drop relay.ditto.pub and the nostr1.com hosting tier; 300s pings drop even relay.snort.social. 120s holds every tier >=240s. - Lowering below 120s would only rescue a ~120s tier (6 relays) that already cycles today under 120s pings (110s/120s both fail; 55s works). Net: 120s is the measured sweet spot; the mobile/wifi split is removed and the constant now documents why. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../2026-07-12-relay-ping-interval-study.md | 245 ++++++++++++++++++ .../okhttp/OkHttpClientFactoryForRelays.kt | 34 +-- 2 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 amethyst/plans/2026-07-12-relay-ping-interval-study.md diff --git a/amethyst/plans/2026-07-12-relay-ping-interval-study.md b/amethyst/plans/2026-07-12-relay-ping-interval-study.md new file mode 100644 index 0000000000..66e75b20b9 --- /dev/null +++ b/amethyst/plans/2026-07-12-relay-ping-interval-study.md @@ -0,0 +1,245 @@ +# WebSocket Ping Interval Study — 122 Production Relays + +**Date:** 2026-07-12 +**Question:** Would relays drop Amethyst's connections if the client WebSocket +ping interval were raised (e.g. 120s → 240s on mobile data to save battery)? +Was the long-standing 120s value ever load-bearing, and what is the best +middle ground? + +**Answer (TL;DR):** Keep a single **120s** ping interval on every network. +Raising it to 240s saves almost no battery — 90% of surveyed relays send +their *own* pings every 30–70s, which OkHttp must answer, so the radio's +wake cadence is set by the relays, not by our interval — and it starts +dropping real relay tiers: 240s pings lose `relay.ditto.pub` (~240s idle +timeout) and every `nostr1.com`-hosted relay (~300s tier); 300s pings even +lose `relay.snort.social` (~600s tier). Lowering below 120s would only +rescue a ~120s tier of 6/122 relays that already cycle today, at 2× the +ping traffic on every other connection. 120s is, by measurement, the sweet +spot it was presumably never designed to be. + +--- + +## 1. Motivation + +`OkHttpClientFactoryForRelays` sets `pingInterval(120s)` on every relay +WebSocket. During battery work the interval was tentatively doubled on +mobile data on the theory that each client ping on an otherwise-idle +cellular connection wakes the radio and pays the multi-second tail-energy +cost. The maintainer asked the right question: *do we actually know how +production relays react to different ping intervals?* Nobody had tested +the 120s value. This study answers it empirically. + +Two distinct drop mechanisms are in play: + +1. **Relay/reverse-proxy idle timeouts** — testable from any vantage. +2. **Carrier NAT idle timeouts** — only testable from a real cellular + network (not from this environment; see §7). + +## 2. Relay population + +Production relays were harvested by fetching **600 kind:10002 (NIP-65) +relay-list events** from indexer relays (`indexer.coracle.social`, +`user.kindpag.es`) and counting `r`-tag references: **1,468 distinct +relays**, ranked by how many users actually list them. The **top 140** +(plus all Amethyst default relays) formed the test population. + +- **122 relays accepted a WebSocket** from the test vantage. +- 18 were unreachable *from a datacenter IP* (Cloudflare 403 challenges: + `nostr.wine`, `relay.0xchat.com`; TCP resets: `relay.nostr.band`, + `nostr.bitcoiner.social`, `relayable.org`, `nostr.fmt.wiz.biz`; plus + ordinary 5xx/410s). These blocks are IP-reputation-based, not + ping-related, and don't affect the conclusions — but they mean the + study cannot speak for those relays. + +## 3. Method + +Three experiments, all through the same stack (Python `websocket-client`, +TLS, one REQ per connection whose filter matches nothing, so the relay +answers EOSE and the connection then carries zero application traffic). +Server pings were always answered with pongs automatically (as OkHttp +does) and logged. + +- **Phase A — idle survival.** 140 relays, **zero client pings**, hold + for **780s (13 min)**. Records: drop time, close code, server-ping + timestamps. A relay surviving 780s of total client-ping silence proves + *any* client interval ≤ 780s is safe for it. +- **Phase B — ping efficacy.** Every Phase A dropper re-tested with + client pings at **55 / 110 / 120 / 180 / 240 / 300s** (one connection + per interval, window = observed idle timeout + 2 ping cycles + margin, + capped at 780s). This distinguishes "pings reset the relay's idle + timer" from "only data frames count". +- **Case study —** `relay.ditto.pub` with 60s pings for 420s (it had + dropped an idle connection at 257s while *its own* ping got our pong at + 123s — proving pongs don't reset its timer but client pings do). + +## 4. Phase A results — idle survival with zero client pings + +**99 of 122 relays (81%) survived 13 minutes of complete client-ping +silence.** For four out of five relays, the client ping interval is +irrelevant to connection survival at any plausible value. + +The 23 droppers cluster into clean idle-timeout tiers: + +| Tier | Count | Relays | +|---|---|---| +| < 30s (probe rejected / non-idle close) | 2 | `nostr.petrkr.net/strfry`, `next.nsite.run` | +| **~60s** | 8 | `nostr.pareto.space` (47s), `nostr.vps.satsnode.xyz` (×2), `relay.mostro.network`, `nostr.bond/alpha`, `nostr.sgiath.dev`, `nostr.bitcoinplebs.de`, `nostr.schneimi.de` | +| **~120s** | 8 | `cfrelay.snowcait.workers.dev` (118s), `nostr-verified.wellorder.net` (120.7s), `nostr-pub.wellorder.net` (120.8s), `git.shakespeare.diy` (125.7s), `nostr-relay.irgenius.org` (126.0s), `nostr-verif.slothy.win` (126.1s), `nostr.bit4use.com` (126.6s), `sendit.nosflare.com` (131.4s) | +| **~240s** | 1 | `relay.ditto.pub` (240.9s; 257.1s in an earlier run) | +| **~300s** | 2 | `david.nostr1.com` (300.5s), `dkkc.nostr1.com` (300.7s) — i.e. the **nostr1.com / relay.tools hosting tier** | +| **~600s** | 2 | `nos.lol/` (600.8s), `relay.snort.social` (601.0s) | + +### Server-ping cadence (the finding that reframes the question) + +Among the 99 relays that held an idle connection for the full window: + +| Server→client ping cadence | Relays | +|---|---| +| ≤ 35s | 45 | +| 36–70s | 37 | +| ~300s | 8 | +| no server pings at all | 9 | + +**90 of 99 relays ping the client; 82 of them every ≤ 70s.** OkHttp +answers every server ping with a pong regardless of the client-side +`pingInterval`. So on a connected cellular device the radio is being +woken every 30–70s *per connection* by the relays themselves. Changing +the client interval from 120s to 240s does not change that cadence at +all — the client ping is a rounding error in the connection's keepalive +traffic. **The claimed battery saving of a longer client ping interval +does not exist in practice.** (Corollary: the real mobile-battery lever +is connected time and connection count in the background — which the +app already minimizes by disconnecting 30s after backgrounding — not +the ping schedule.) + +## 5. Phase B results — which client intervals keep the droppers alive + +For every idle-dropper, one connection per candidate interval +(`x@T` = dropped at T seconds despite pinging at that interval; +`skip` = interval ≥ observed idle timeout, unsafe by construction): + +| relay | idle-drop | 55s | 110s | 120s | 180s | 240s | 300s | max safe | +|---|---|---|---|---|---|---|---|---| +| nostr.pareto.space | 46.9s | skip | skip | skip | skip | skip | skip | none | +| nostr.vps.satsnode.xyz | 51.1s | skip | skip | skip | skip | skip | skip | none | +| nostr.vps.satsnode.xyz/… | 51.2s | skip | skip | skip | skip | skip | skip | none | +| relay.mostro.network | 60.5s | x@60.8 | skip | skip | skip | skip | skip | none | +| nostr.bond/alpha | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none | +| nostr.sgiath.dev | 60.8s | x@60.9 | skip | skip | skip | skip | skip | none | +| nostr.bitcoinplebs.de | 60.9s | x@61.1 | skip | skip | skip | skip | skip | none | +| nostr.schneimi.de | 62.2s | x@61.1 | skip | skip | skip | skip | skip | none | +| cfrelay.snowcait.workers.dev | 118.2s | x@85.0 | x@74.5 | skip | skip | skip | skip | none | +| nostr-verified.wellorder.net | 120.7s | **OK** | x@120.7 | x@120.8 | skip | skip | skip | 55s | +| nostr-pub.wellorder.net | 120.8s | **OK** | x@120.8 | x@120.8 | skip | skip | skip | 55s | +| git.shakespeare.diy/… | 125.7s | **OK** | x@125.9 | x@126.1 | skip | skip | skip | 55s | +| nostr-relay.irgenius.org | 126.0s | **OK** | x@125.8 | x@125.9 | skip | skip | skip | 55s | +| nostr-verif.slothy.win | 126.1s | **OK** | x@126.1 | x@126.3 | skip | skip | skip | 55s | +| nostr.bit4use.com | 126.6s | **OK** | x@126.4 | x@126.5 | skip | skip | skip | 55s | +| sendit.nosflare.com | 131.4s | x@81.7 | x@41.9 | x@7.0 | skip | skip | skip | none | +| **relay.ditto.pub** | 240.9s | OK | OK | **OK** | x@673.5 | **x@609.8** | skip | **120s** | +| **david.nostr1.com** | 300.5s | OK | OK | **OK** | x@300.6 | **x@300.7** | x@300.7 | **120s** | +| **dkkc.nostr1.com/…** | 300.7s | OK | OK | **OK** | x@300.7 | **x@301.1** | x@300.6 | **120s** | +| nos.lol/ | 600.8s | OK | OK | OK | OK | OK | x@602.1 | 240s | +| **relay.snort.social** | 601.0s | OK | OK | OK | OK | OK | **x@607.7** | 240s | + +Key observations: + +1. **A client ping interval numerically below the idle timeout is NOT + sufficient.** 180s and 240s pings failed against the ~300s + `nostr1.com` tier, and 300s pings failed against the ~600s + `snort.social` tier, even though each ping "should" have arrived in + time. The empirical rule across every tier: **pings only reliably + reset a relay's idle timer when the interval is at most roughly half + the timeout.** (Likely cause: these stacks check activity in coarse + windows rather than resetting a precise per-frame deadline, so an + interval near the window size loses boundary races.) +2. The **~60s tier is unsalvageable** — even 55s pings didn't help + (their timers count only data frames). These 8 relays drop idle + Amethyst connections *today* under the 120s setting and would under + any setting; the existing reconnect-on-demand path is the correct + handling for them. +3. The **~120s tier is only rescued by ≤55s pings** — meaning + **today's 120s interval never kept them alive either** (110s and + 120s pings both failed). They cycle today; they'd cycle at 240s. + No candidate change affects them. +4. The tiers that DO depend on our ping interval are exactly + **ditto (~240s), nostr1.com (~300s), and snort/nos.lol-haven + (~600s)** — and 120s holds all of them, while 240s loses the first + two and 300s loses all three. + +Connections kept alive (of 122 reachable), by candidate interval: +**55s → 110 · 120s → 104 · 240s → 101 · 300s → 99.** + +The `relay.ditto.pub` case study confirms the mechanism: with an idle +connection its *own* ping at t=123s received our pong and it still +closed at 257s (pongs don't count as activity), but with 60s client +pings it stayed up indefinitely (client pings do count). + +## 6. Why not go lower than 120s? + +55s pings would rescue the ~120s tier (6 relays). But: + +- those relays already cycle today, so the status quo loses nothing; +- 55s pings double the client-ping traffic on all ~100+ connections to + rescue 5% of relays whose operators chose aggressive timeouts; +- the radio is already woken every ≤70s on 82/122 connections by server + pings, so the *incremental* battery cost is modest — but so is the + benefit, and drop/reconnect for those 6 relays is already handled + gracefully by `BasicRelayClient`'s backoff + the keep-alive sweep. + +A per-relay adaptive interval (shorten pings only for relays observed to +drop idle connections) is possible future work, but OkHttp's +`pingInterval` is per-client, not per-socket, so it would require +per-relay client instances — not worth the complexity for 6 relays. + +## 7. Carrier NAT — the part this study cannot measure + +The other purpose of client pings is keeping carrier NAT/firewall +mappings alive on cellular. That is untestable from a datacenter vantage. +Published measurements and platform folklore put aggressive carrier TCP +idle timeouts around 4–5 minutes (most are 15–30 min; FCM survives on +~28 min heartbeats *with OS cooperation Amethyst doesn't get*). 120s +sits comfortably inside even the aggressive bound, so relay-side and +NAT-side constraints agree on the same answer. Anyone wanting to raise +the interval later must first re-run Phase B *and* validate on real +cellular networks — the relay data alone already rules out 240s. + +## 8. Decision + +- **`WEBSOCKET_PING_INTERVAL_SECS = 120`, one value for wifi and mobile.** + The tentative 240s mobile value was reverted in this same branch after + these measurements: it saved ~nothing (server pings dominate radio + wakes) and dropped the ditto and nostr1.com tiers. +- OkHttp's `pingInterval` doubles as the dead-connection detector (a + missed pong fails the socket within one interval), so 120s also keeps + failure detection twice as fast as 240s would — relevant after silent + network path changes. + +## 9. Reproduction + +Vantage caveats: datacenter egress IP (18 relays refused it), all +traffic via an HTTP CONNECT proxy. A control connection with 100s pings +survived every window, ruling out proxy-imposed idle limits ≤ 780s. + +Sketch (Python `websocket-client`): open `wss://` to each relay, send +one REQ whose filter matches nothing (`{"kinds":[1],"authors":["00…01"], +"limit":1}`), auto-pong server pings, and either never ping (Phase A, +780s window) or ping at the candidate interval (Phase B). Log connect / +EOSE / server-ping / close timestamps. Population: top-N relays by +`r`-tag frequency across kind:10002 events fetched from indexer relays. + +## Appendix — Phase A survivor cadences (99 relays) + +Server-ping cadence measured over the 13-minute window. `none` means the +relay sent no pings at all and still held the idle connection. + +| cadence | relays | +|---|---| +| ~25–35s | `articles.layer3.news`, `aegis.relayted.de`, `assistantrelay.rodbishop.nz`, `bots.utxo.one`, `custom.fiatjaf.com`, `dev.calendar-relay.edufeed.org`, `greensoul.space` (×2), `groups.0xchat.com`, `groups.satsdisco.com`, `h.codingarena.top/inbox`, `haven.calva.dev/inbox`, `haven.nostrfreedom.net`, `haven.relayted.de`, `hist.nostr.land`, `lang.relays.land` (×3), `nexus.libernet.app`, `nip17.com`, `nostr-01.uid.ovh`, `nostr-relay.derekross.me` (×2), `nostr.damupi.com/inbox`, `nostr.easydns.ca`, `nostr.kfx.fr` (×2), `nostr.land`, `nostr.nothing.is-lost.org/haven`, and 17 more at ~30s; `nostrelites.org`, `purplepag.es`, `relay.noswhere.com` at ~30s | +| ~55–70s | `nostr.thalheim.io`, `nostr.xmr.rocks`, `offchain.pub`, `relay.mostr.pub`, `relay.nostr.net`, `relay.primal.net`, `relay.damus.io`, `indexer.coracle.social`, `directory.yabu.me`, `user.kindpag.es`, `profiles.nostr1.com`, `nostr.oxtr.dev`, and ~25 more | +| ~300s | `nostr-relay.corb.net`, `nostr.001.j5s9.dev`, `nostr.8777.ch`, `nostr.einundzwanzig.space`, `nostr.mikoshi.de`, `nostr.pbfs.io`, `nostr.sectiontwo.org`, `nostr.wild-vibes.ts.net` | +| none | `nos.lol`, `nostr.mom`, `relay.divine.video`, `relay.fountain.fm`, `koru.bitcointxoko.org`, `nostr-pr02.redscrypt.org`, 2 × Cloudflare-Workers relays, 1 other | + +Raw JSON for both phases (per-relay timestamps, close codes, server-ping +series) was captured during the study session; the tables above are the +complete decision-relevant summary. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt index 1f826aad05..1795fcd0f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelays.kt @@ -41,15 +41,19 @@ class OkHttpClientFactoryForRelays( const val DEFAULT_TIMEOUT_ON_WIFI_SECS: Int = 10 const val DEFAULT_TIMEOUT_ON_MOBILE_SECS: Int = 30 - // Every WebSocket ping on an otherwise-idle cellular connection promotes - // the radio out of its low-power state and pays the multi-second "tail" - // energy cost — per connection. On mobile data the interval is doubled to - // halve those wake-ups while staying under the ~5-minute idle timeout of - // the most aggressive carrier NATs (so connections aren't silently dropped - // between pings). Wifi keeps the tighter interval: pings there are cheap - // and detect dead connections sooner. - const val WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS: Long = 120 - const val WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS: Long = 240 + // 120s is a measured sweet spot, not a guess — see + // amethyst/plans/2026-07-12-relay-ping-interval-study.md (probe of 122 + // production relays). Idle-timeout tiers observed in production are + // ~60s / ~120s / ~240s / ~300s / ~600s, and a client ping only reliably + // resets a relay's idle timer when the interval sits well BELOW the + // tier (240s pings already lose the ~240s and ~300s tiers, incl. every + // nostr1.com-hosted relay; 300s pings lose relay.snort.social). Raising + // the interval also saves almost no battery: 90% of surveyed relays + // send their own server pings every 30-70s, which OkHttp must answer, + // so the radio's wake cadence is set by the relays, not by this value. + // Lowering it would only rescue the ~120s tier (6/122 relays, already + // cycling today) at 2x the ping traffic on every other connection. + const val WEBSOCKET_PING_INTERVAL_SECS: Long = 120 } val myDispatcher = @@ -87,7 +91,6 @@ class OkHttpClientFactoryForRelays( fun buildHttpClient( proxy: Proxy?, timeoutSeconds: Int, - pingIntervalSeconds: Long = WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS, ): OkHttpClient { if (proxy != lastProxy) { rootClient.connectionPool.evictAll() @@ -101,7 +104,7 @@ class OkHttpClientFactoryForRelays( .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) - .pingInterval(Duration.ofSeconds(pingIntervalSeconds)) + .pingInterval(Duration.ofSeconds(WEBSOCKET_PING_INTERVAL_SECS)) .build() } @@ -112,14 +115,12 @@ class OkHttpClientFactoryForRelays( buildHttpClient( buildLocalSocksProxy(localSocksProxyPort), buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), - buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildHttpClient(isMobile: Boolean?): OkHttpClient = buildHttpClient( null, buildTimeout(isMobile ?: DEFAULT_IS_MOBILE), - buildPingInterval(isMobile ?: DEFAULT_IS_MOBILE), ) fun buildTimeout(isMobile: Boolean): Int = @@ -129,12 +130,5 @@ class OkHttpClientFactoryForRelays( DEFAULT_TIMEOUT_ON_WIFI_SECS } - fun buildPingInterval(isMobile: Boolean): Long = - if (isMobile) { - WEBSOCKET_PING_INTERVAL_ON_MOBILE_SECS - } else { - WEBSOCKET_PING_INTERVAL_ON_WIFI_SECS - } - fun buildLocalSocksProxy(port: Int?) = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: DEFAULT_SOCKS_PORT)) } From a760d17eaa2dd34afc8385db2fa83ec40756ba4d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 19:32:20 +0000 Subject: [PATCH 083/206] test(battery): behavioral tests for the keep-alive pause and worker gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ping-interval change shipped on reasoning and was overturned by measurement; these tests give the remaining battery changes the same scrutiny where the claims are about OUR code (deterministically testable), instead of leaving them reasoning-only: - NostrClientKeepAliveTest (quartz): a server-closed relay is redialed within one 60s sweep while active; ZERO dials happen while the client is inactive no matter how much time passes; and the sweep provably resumes after connect() — the failure mode a suspend/resume bug in the new isActiveFlow gate would cause. - ScheduledPostWorkGate extracted from AppModules + tests: schedules on the first PENDING post, cancels when the last drains, re-schedules on publishNow-retry, and — the race that motivated the extraction — never emits a spurious cancel from the store flow's empty placeholder before the disk load completes. - CalendarReminderWorker.couldStillFire extracted + tests against LocalCache: future target keeps the chain, past target lets it end, and an unresolved/start-less target keeps it alive so a reminder can't be lost while the target event is still being fetched. The watchdog alarm change (wakeup -> non-wakeup) remains reasoning-only by nature: it asserts Android OS alarm semantics on OEM devices, which no JVM test can exercise; its blast radius is limited to opt-in always-on users with two independent restart layers still active. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../com/vitorpamplona/amethyst/AppModules.kt | 35 ++-- .../calendar/CalendarReminderWorker.kt | 52 +++--- .../scheduledposts/ScheduledPostWorkGate.kt | 65 ++++++++ .../CalendarReminderCandidatesTest.kt | 149 +++++++++++++++++ .../ScheduledPostWorkGateTest.kt | 149 +++++++++++++++++ .../relay/client/NostrClientKeepAliveTest.kt | 154 ++++++++++++++++++ 6 files changed, 561 insertions(+), 43 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index fbdd4b444e..0d1a92ff48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -87,8 +87,8 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFind import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.safeCacheDir -import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore +import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe @@ -893,30 +893,19 @@ class AppModules( notificationDispatcher.start() // Keep the scheduled-posts worker (15-min periodic + one-time catch-up) - // enqueued exactly while the store holds a PENDING post. An always-on - // periodic worker wakes — and often cold-starts — the whole process every - // 15 minutes forever, even for users who never schedule a post. The store - // is durable (JSON on disk) and the single source of truth, so the worker - // is (re-)enqueued from any mutation that produces a PENDING row and - // cancelled when the last one drains. Runs independently of the always-on + // enqueued exactly while the store holds a PENDING post — see + // ScheduledPostWorkGate. Runs independently of the always-on // notification setting so scheduled posts still fire when always-on // notifications are disabled. - applicationIOScope.launch { - // Force the initial disk load; the flow's initial value is an empty - // list until the store is first touched. - scheduledPostStore.list() - scheduledPostStore.flow - .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } - .distinctUntilChanged() - .collect { hasPending -> - if (hasPending) { - ScheduledPostWorker.schedule(appContext) - ScheduledPostWorker.scheduleCatchUp(appContext) - } else { - ScheduledPostWorker.cancelPeriodic(appContext) - } - } - } + ScheduledPostWorkGate( + store = scheduledPostStore, + scope = applicationIOScope, + onPendingWork = { + ScheduledPostWorker.schedule(appContext) + ScheduledPostWorker.scheduleCatchUp(appContext) + }, + onNoPendingWork = { ScheduledPostWorker.cancelPeriodic(appContext) }, + ).start() // "Starting soon" reminders for NIP-52 appointments the user RSVP'd to as // ACCEPTED. The 15-min periodic scanner is only scheduled while it can diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 4560fe86e1..a68089408f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -76,12 +76,7 @@ class CalendarReminderWorker( // multi-account "all logged-in pubkeys" view here, so we accept any RSVP that's // present in cache — the alternative (looking only at the foreground account) would // silently break notifications for account switching during the lead window. - val acceptedRsvps = - LocalCache.addressables - .filterIntoSet { _, note -> - val e = note.event - e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED - }.mapNotNull { it.event as? CalendarRSVPEvent } + val acceptedRsvps = acceptedRsvpsInCache() Log.d(TAG) { "Worker scanning ${acceptedRsvps.size} accepted RSVPs (now=$now, lead=${prefs.leadMinutes()}m)" } @@ -122,22 +117,10 @@ class CalendarReminderWorker( store.forgetBefore(now - PRUNE_AGE_SECONDS) // Nothing left that could ever fire → end the periodic chain instead of - // waking the process every 15 minutes forever. An RSVP whose target event - // hasn't been fetched yet (start unknown) counts as "could still fire" so - // the chain survives until the target resolves. The ACCEPTED-RSVP observer + // waking the process every 15 minutes forever. The ACCEPTED-RSVP observer // in AppModules re-schedules the worker the next time a live session sees // an accepted RSVP. - val couldStillFire = - acceptedRsvps.any { rsvp -> - val targetAddress = rsvp.calendarEventAddress() ?: return@any false - val start = - LocalCache.addressables - .get(targetAddress) - ?.appointmentView() - ?.startSeconds - start == null || start > now - } - if (!couldStillFire) { + if (!couldStillFire(acceptedRsvps, now)) { Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } cancel(applicationContext) } @@ -152,6 +135,35 @@ class CalendarReminderWorker( // more than a day ago; they can't fire again so the entry is pure overhead. private const val PRUNE_AGE_SECONDS = 24L * 60L * 60L + /** Every ACCEPTED kind-31925 RSVP currently present in LocalCache. */ + fun acceptedRsvpsInCache(): List = + LocalCache.addressables + .filterIntoSet { _, note -> + val e = note.event + e is CalendarRSVPEvent && e.status() == RSVPStatusTag.STATUS.ACCEPTED + }.mapNotNull { it.event as? CalendarRSVPEvent } + + /** + * True while at least one accepted RSVP could still produce a reminder: + * its target event either starts in the future, or hasn't been fetched + * yet (start unknown — the chain must survive until the target + * resolves). False means the periodic worker has nothing it could ever + * notify about and may cancel its own chain. + */ + fun couldStillFire( + rsvps: Collection, + now: Long, + ): Boolean = + rsvps.any { rsvp -> + val targetAddress = rsvp.calendarEventAddress() ?: return@any false + val start = + LocalCache.addressables + .get(targetAddress) + ?.appointmentView() + ?.startSeconds + start == null || start > now + } + fun schedule(context: Context) { val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt new file mode 100644 index 0000000000..3cda10674b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt @@ -0,0 +1,65 @@ +/* + * 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.service.scheduledposts + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +/** + * Keeps [ScheduledPostWorker]'s 15-minute periodic chain enqueued exactly while + * the store holds a PENDING post. An unconditionally-scheduled periodic worker + * wakes — and often cold-starts — the whole process every 15 minutes forever, + * even for users who never schedule a post. + * + * The store is durable (JSON on disk) and the single source of truth, so + * [onPendingWork] fires from any mutation that produces a PENDING row (add, + * publishNow, releaseClaim) and [onNoPendingWork] when the last one drains + * (markSent/markFailed/cancel/removeForAccount). + * + * [start] forces the store's initial disk load BEFORE collecting: the flow's + * initial value is an empty list until the store is first touched, and acting + * on that placeholder would cancel scheduled work that a pending post still + * needs. + */ +class ScheduledPostWorkGate( + private val store: ScheduledPostStore, + private val scope: CoroutineScope, + private val onPendingWork: () -> Unit, + private val onNoPendingWork: () -> Unit, +) { + fun start(): Job = + scope.launch { + store.list() + store.flow + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .distinctUntilChanged() + .collect { hasPending -> + if (hasPending) { + onPendingWork() + } else { + onNoPendingWork() + } + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt new file mode 100644 index 0000000000..74347b6d61 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/calendar/CalendarReminderCandidatesTest.kt @@ -0,0 +1,149 @@ +/* + * 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.calendar + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The predicate that decides whether the CalendarReminderWorker's periodic + * chain may cancel itself. Getting it wrong in either direction is a bug: + * a false "could fire" keeps waking the process forever; a false "can't fire" + * silently kills a reminder the user RSVP'd to. + */ +class CalendarReminderCandidatesTest { + private val now = 2_000_000_000L + + // Unique per-test-class identities so the shared LocalCache singleton + // doesn't collide with other suites running in the same JVM. + private val organizer = "b0b0000000000000000000000000000000000000000000000000000000000001" + private val attendee = "a11ce00000000000000000000000000000000000000000000000000000000002" + + private var eventSeq = 0 + + private fun timeSlot( + dTag: String, + startSeconds: Long?, + ) = CalendarTimeSlotEvent( + id = "c0ffee%058d".format(++eventSeq), + pubKey = organizer, + createdAt = now - 1000, + tags = + if (startSeconds != null) { + arrayOf(arrayOf("d", dTag), arrayOf("start", startSeconds.toString())) + } else { + arrayOf(arrayOf("d", dTag)) + }, + content = "", + sig = "sig", + ) + + private fun rsvp( + dTag: String, + targetDTag: String?, + status: String = "accepted", + ) = CalendarRSVPEvent( + id = "faced%059d".format(++eventSeq), + pubKey = attendee, + createdAt = now - 900, + tags = + buildList { + add(arrayOf("d", dTag)) + add(arrayOf("status", status)) + if (targetDTag != null) { + add(arrayOf("a", "${CalendarTimeSlotEvent.KIND}:$organizer:$targetDTag")) + } + }.toTypedArray(), + content = "", + sig = "sig", + ) + + @Test + fun acceptedRsvpsInCache_returnsAcceptedAndSkipsDeclined() { + val accepted = rsvp("cand-accepted", "cand-target-1") + val declined = rsvp("cand-declined", "cand-target-2", status = "declined") + LocalCache.justConsume(accepted, null, true) + LocalCache.justConsume(declined, null, true) + + val found = CalendarReminderWorker.acceptedRsvpsInCache() + assertTrue("accepted RSVP must be found", found.any { it.dTag() == "cand-accepted" }) + assertFalse("declined RSVP must be skipped", found.any { it.dTag() == "cand-declined" }) + } + + @Test + fun futureTarget_couldStillFire() { + val slot = timeSlot("cand-future", startSeconds = now + 3600) + val r = rsvp("cand-rsvp-future", "cand-future") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun pastTarget_cannotFireAnymore() { + val slot = timeSlot("cand-past", startSeconds = now - 3600) + val r = rsvp("cand-rsvp-past", "cand-past") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun unresolvedTarget_keepsTheChainAlive() { + // The RSVP points at an event the cache hasn't fetched yet: the start + // is unknown, so the worker must NOT cancel its chain. + val r = rsvp("cand-rsvp-unresolved", "cand-never-fetched") + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun targetWithoutStart_keepsTheChainAlive() { + // Target resolved but carries no start tag: still unknown, keep alive. + val slot = timeSlot("cand-no-start", startSeconds = null) + val r = rsvp("cand-rsvp-no-start", "cand-no-start") + LocalCache.justConsume(slot, null, true) + LocalCache.justConsume(r, null, true) + + assertTrue(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun rsvpWithoutTargetAddress_doesNotKeepTheChainAlive() { + val r = rsvp("cand-rsvp-no-a-tag", targetDTag = null) + LocalCache.justConsume(r, null, true) + + assertFalse(CalendarReminderWorker.couldStillFire(listOf(r), now)) + } + + @Test + fun emptyCache_doesNotKeepTheChainAlive() { + assertFalse(CalendarReminderWorker.couldStillFire(emptyList(), now)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt new file mode 100644 index 0000000000..f3577d52f1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -0,0 +1,149 @@ +/* + * 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.service.scheduledposts + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * The gate must keep the periodic worker enqueued exactly while a PENDING + * post exists — and, critically, must never act on the store flow's empty + * placeholder value before the disk load completes (which would cancel + * scheduled work an existing pending post still needs). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ScheduledPostWorkGateTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + /** Chronological record of gate decisions: true = schedule, false = cancel. */ + private val decisions = mutableListOf() + + @Before + fun setUp() { + file = File(temp.root, "scheduled_posts.json") + decisions.clear() + } + + private fun newStore() = ScheduledPostStore(file) + + private fun TestScope.startGate(store: ScheduledPostStore) = + ScheduledPostWorkGate( + store = store, + scope = backgroundScope, + onPendingWork = { decisions.add(true) }, + onNoPendingWork = { decisions.add(false) }, + ).start() + + private fun samplePost( + id: String = "id-1", + publishAtSec: Long = 4_000_000_000, + ) = ScheduledPost( + id = id, + accountPubkey = "pk1", + signedEventJson = "{}", + relayUrls = listOf("wss://relay.example/"), + extraEventsJson = emptyList(), + publishAtSec = publishAtSec, + createdAtSec = 500, + ) + + @Test + fun emptyStore_cancelsOnceAndOnlyOnce() = + runTest { + startGate(newStore()) + runCurrent() + assertEquals(listOf(false), decisions) + } + + @Test + fun pendingPostOnDisk_schedulesWithoutSpuriousCancelFirst() = + runTest { + // Persist a pending post in a previous "process". + runBlocking { newStore().add(samplePost()) } + + // Fresh store (flow starts as the empty placeholder). The gate must + // load from disk before collecting: the FIRST decision must be + // "schedule", never a cancel from the placeholder value. + startGate(newStore()) + runCurrent() + assertEquals(listOf(true), decisions) + } + + @Test + fun addThenDrainThenAddAgain_togglesTheWorker() = + runTest { + val store = newStore() + startGate(store) + runCurrent() + assertEquals(listOf(false), decisions) + + store.add(samplePost(id = "a")) + runCurrent() + assertEquals(listOf(false, true), decisions) + + // A second pending post must not re-fire (distinctUntilChanged). + store.add(samplePost(id = "b")) + runCurrent() + assertEquals(listOf(false, true), decisions) + + // Draining both pending posts cancels the periodic chain. + store.markSent("a") + runCurrent() + assertEquals(listOf(false, true), decisions) + store.markFailed("b", "boom") + runCurrent() + assertEquals(listOf(false, true, false), decisions) + + // A new post re-schedules. + store.add(samplePost(id = "c")) + runCurrent() + assertEquals(listOf(false, true, false, true), decisions) + } + + @Test + fun publishNowOnFailedPost_reschedulesTheWorker() = + runTest { + val store = newStore() + store.add(samplePost(id = "a")) + startGate(store) + runCurrent() + store.markFailed("a", "relay down") + runCurrent() + assertEquals(listOf(true, false), decisions) + + // Retry flips the post back to PENDING; the worker must come back. + store.publishNow("a") + runCurrent() + assertEquals(listOf(true, false, true), decisions) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt new file mode 100644 index 0000000000..66137b9ff8 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/NostrClientKeepAliveTest.kt @@ -0,0 +1,154 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.FakeWebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Behavioral contract of the keep-alive sweep after it was changed to suspend + * on the client's active-state flow instead of ticking unconditionally: + * + * 1. while ACTIVE, a relay closed by the server is redialed within one 60s + * sweep interval (once the per-relay backoff gate opens); + * 2. while INACTIVE (host called [NostrClient.disconnect], i.e. the app went + * to the background), no amount of elapsed time produces a dial; + * 3. after [NostrClient.connect] reactivates the client, redialing works + * again — a bug in the suspend/resume logic would leave every + * server-dropped relay disconnected forever. + * + * Coroutine timers (keep-alive 60s, reconnect debounce 200ms) run on the test + * scheduler's virtual clock; the per-relay backoff gate compares real wall + * seconds (integer granularity), hence the short real sleeps before each + * expected redial. + * + * Harness note: every socket the client dials must eventually be driven to + * onOpen/onClosed — a [FakeWebsocketBuilder] socket that is never completed + * leaves the relay in "connecting" forever and blocks all later dials, which + * is exactly what a production dial never does. [settleDisconnected] flushes + * pending debounced reconnects and completes any socket they may open. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NostrClientKeepAliveTest { + private val url = NormalizedRelayUrl("wss://keepalive.example.com") + + /** + * Flushes pending sub-second timers (the 200ms reconnect debounce) and, + * if a flush dialed a new socket, completes its lifecycle so the relay + * ends DISCONNECTED with no pending timers besides the keep-alive sweep. + */ + private fun TestScope.settleDisconnected(builder: FakeWebsocketBuilder) { + repeat(4) { + val before = builder.connectAttempts + advanceTimeBy(500) + runCurrent() + if (builder.connectAttempts == before) return + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "test settle") + } + } + + @Test + fun keepAliveSweepPausesWhileInactiveAndResumesAfterReconnect() = + runTest { + val builder = FakeWebsocketBuilder() + val client = NostrClient(builder, this) + try { + // Flush refreshConnection's initial emission against the + // still-empty pool so it can't dial later. + advanceTimeBy(500) + runCurrent() + + // Subscribing dials the relay immediately. + client.subscribe("sub", mapOf(url to listOf(Filter()))) + assertEquals(1, builder.connectAttempts) + + // Server closes the established connection. While ACTIVE the + // client must redial within one keep-alive interval. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + val beforeActiveSweep = builder.connectAttempts + Thread.sleep(3_500) + advanceTimeBy(61_000) + runCurrent() + assertTrue( + builder.connectAttempts > beforeActiveSweep, + "active client should redial a server-closed relay within one sweep, " + + "got ${builder.connectAttempts} attempts (baseline $beforeActiveSweep)", + ) + + // Leave the relay cleanly disconnected, then deactivate. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + client.disconnect() + val whileInactiveBaseline = builder.connectAttempts + + // The relay's backoff gate is reset by disconnect(), so any + // stray sweep tick WOULD dial — this fails if the sweep keeps + // running (or anything else dials) while inactive. + Thread.sleep(3_500) + advanceTimeBy(30 * 60_000) + runCurrent() + assertEquals( + whileInactiveBaseline, + builder.connectAttempts, + "no dial may happen while the client is inactive", + ) + + // Reactivation dials the pool immediately... + client.connect() + runCurrent() + assertEquals( + whileInactiveBaseline + 1, + builder.connectAttempts, + "connect() should redial the pool immediately", + ) + + // ...and after the pause a server-closed relay must again be + // redialed within one sweep — fails if the sweep never + // resumes after the inactive stretch. + builder.lastListener.onOpen(50, false) + builder.lastListener.onClosed(1000, "server closed") + settleDisconnected(builder) + val beforeResumedSweep = builder.connectAttempts + Thread.sleep(3_500) + advanceTimeBy(61_000) + runCurrent() + assertTrue( + builder.connectAttempts > beforeResumedSweep, + "sweep did not resume after connect(); a server-dropped relay would stay disconnected forever", + ) + } finally { + client.close() + } + } +} From 8065b045e45e226e0a71b7cf3e6b13d6e774c87a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:06:20 +0000 Subject: [PATCH 084/206] 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 8e46714fcaa7ae85ac6bde208d2374ca0f775b02 Mon Sep 17 00:00:00 2001 From: davotoula <1747287+davotoula@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:06:46 +0000 Subject: [PATCH 085/206] chore: sync Crowdin translations and seed translator npub placeholders --- .../src/main/res/values-hi-rIN/strings.xml | 36 ++-- .../src/main/res/values-hu-rHU/strings.xml | 9 +- .../src/main/res/values-sl-rSI/strings.xml | 187 +++++++++++++++++- docs/changelog/translators.json | 12 +- 4 files changed, 207 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index af4c9b8139..90d73c13a3 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -791,7 +791,7 @@ अनुकूलित स्पष्ट चयन करें किन पुनःप्रसारकों में प्रवेशांकन करना चाहिए। नीचे जिसको अनुमति नहीं दी गई उन सब के विषय में आप पूछे जाएँगे। मेरे पुनःप्रसारक तथा आयोजन स्थल - प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा सीधे प्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं। + प्रवेशांकन करें आपके अपने पुनःप्रसारक तथा सार्वजनिक चर्चाएँ तथा समुदाय तथा वर्तमानप्रवाहों को जिनसे आप जुडे हैं अथवा प्रियचिह्नित किए हैं। मेरे द्वारा अनुचरित लोगों के पत्र पढें अनुचरित के पुनःप्रसारकों में प्रवेशांकन करें उनके पत्र प्राप्त करने के लिए। मेरे द्वारा अनुचरित लोगों को सन्देश भेजें @@ -845,7 +845,7 @@ लघु चलचित्र सार्वजनिक चर्चा अनुचरण पोटलियाँ - सीधा प्रसारण + वर्तमानप्रवाह ध्वनि शाला %1$s में एक निलय @@ -871,14 +871,14 @@ मंच छोडें ध्वनिग्राहक मौन ध्वनिग्राहक सक्रिय - सीधा प्रसारण प्रारम्भ… + वर्तमानप्रवाह प्रारम्भ… प्रसारण असफल : %1$s मौनकरण असफल : %1$s कोई अनुमति नहीं निलय ध्वनि चालू रखता है जब शाला खुला है। निलय संयोजित - निलय - सीधाप्रसार + निलय - वर्तमान लौटने के लिए दबाएँ। अवरोधन निलय जुडें @@ -906,7 +906,7 @@ %1$d श्रोता %1$d श्रोतागण - सीधाप्रसार + वर्तमान चर्चा श्रोतागण हाथ @@ -951,7 +951,7 @@ इस शाला में ध्वनि सेवा अथवा गन्तव्य उपलब्ध नहीं। निमन्त्रक को इसके विवरणों का शोधन करना चाहिए श्रोतागण जुडने से पहले। पीछे स्थान आरम्भण - सीधाप्रसार चालू + वर्तमानप्रवाह चालू समयनिर्धारित निकटकाल समाप्त समाप्त %1$s पूर्व @@ -1605,7 +1605,7 @@ विषयवस्तु आकार संयोजकता अभिगमन नियन्त्रण - न्यूनतम श्रमप्रमाण + न्यूनतम श्रमप्रमाण कठिनाई प्रमाणीकरण आवश्यक भुगतान आवश्यक अधिकतम संदेश लम्बाई @@ -1619,13 +1619,13 @@ Token कॉपी करें अन्य क्रमक में खोलें कोई लैटनिंग पता स्थापित नहीं - तत्क्षणप्रसार - जालरहित + वर्तमान + असंयोजित समाप्त समय निर्धारित निजी - तत्क्षणप्रसार कट गया - तत्क्षणप्रसार समाप्त + वर्तमानप्रवाह कट गया + वर्तमानप्रवाह समाप्त खुला निजी आवृत @@ -1639,7 +1639,7 @@ पठितव्य सूचनावली विधियाँ व्यापारक्षेत्र - तत्क्षणप्रसार + वर्तमानप्रवाह समुदाय चर्चाएँ अनुमति प्राप्त पत्र @@ -2520,7 +2520,7 @@ बाँटें अथवा अभिलेखन करें चलचित्र योजक बाहर बाँटें संचारयन्त्र में अभिलेखन करें - चलचित्र का अवरोहण करें आपके यन्त्र तक (सीधे प्रसारों में अदृश्य) + चलचित्र का अवरोहण करें आपके यन्त्र तक (वर्तमानप्रवाहों में अदृश्य) चित्र में चित्र चलचित्र दिखाएँ तैरते गवाक्ष में (अदृश्य यदि अनालम्बित) यन्त्र पर निक्षेपण @@ -3023,8 +3023,8 @@ साहसिक दृश्य साहसिक पठन नाम युक्त स्मर्त्तव्य सूची - सीधी बातचीत - सीधा प्रसारण + वर्तमान बातचीत + वर्तमानप्रवाह ज्साप नोस्टर धनकोष अनुरोध नोस्टर धनकोष प्रत्युत्तर @@ -3246,7 +3246,7 @@ आरोहण कृत %2$d में से %1$d घटना प्रकाशन चालू… दृश्याभिलेख प्रकाशित - आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर चलन्त है। + आपका उच्छनिरूपण दृश्याभिलेख नोस्टर पर वर्तमान है। कुछ गडबड हुआ हो गया पुनः प्रयास करें @@ -3431,7 +3431,7 @@ लेख शोधन प्रस्ताव करें यन्त्र स्थित एआई॰ प्रतिरूप का प्रयोग करता है लेख सुधार तथा स्वर परिवर्तन प्रस्तावों के लिए। पदचिह्न युक्त प्रसारण - पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। तत्काल प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। + पदचिह्न युक्त प्रसारक का उपयोग करें घटनाओं को भेजते समय। वर्तमान प्रगति दिखाता है तथा प्रत्येक पुनःप्रसारक की स्थिति प्रसारण करते समय। व्यायामों का सुझाव बाँटनें के लिए सम्पन्न व्यायाम पढता है स्वास्थ्य संयोजन से (सामसंग स्वास्थ्य तथा गूगिल फिट तथा फिटबिट तथा गार्मिन इत्यादि) तथा पत्र के रूप में बाँटने का सुझाव देता है। अनुमति अनुरोध केवल संयोजन करने पर। सम्पादन स्थापना विकल्प @@ -3462,7 +3462,7 @@ उच्च मात्रा सक्रिय प्रयोक्ताओं के लिए प्रतिक्रियाएँ उच्चतम मात्रा प्रकार। प्रत्येक इष्टसूचक के लिए खनन का परिणाम विद्युत्कोष का व्यय - सार्वजनिक तथा सीधा चर्चा + सार्वजनिक तथा वर्तमान चर्चा खनन विलम्बता से वार्तालाप प्रवाह आहत निजी सन्देश कोष सीधासन्देश पुनःप्रसारकों को आगतपेटिका कचरालेख छालन करने में सक्षम करता है। केवल बाहरी कोष का खनन होता है। आपका सन्देश कभी नहीं diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index ef95c83d52..c667c9f116 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -139,7 +139,8 @@ Zap-igazolás Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on. Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést. - Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. + Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. + A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve. Számla kérése… Számla kérése a Nostr hálózaton keresztül… @@ -793,7 +794,7 @@ Saját átjátszók és helyszínek Bejelentkezés a saját átjátszóiba, valamint azokba a nyilvános csevegésekbe, közösségekbe és élő közvetítésekbe, amelyekhez csatlakozott vagy amelyeket kedvelt. Követett személyek bejegyzéseinek olvasása - Bejelentkezés egy követett személy átjátszóiba a bejegyzéseinek letöltéséhez. + Bejelentkezés egy követett felhasználó átjátszóiba, hogy letölthesse a profiladatait, bejegyzéseit és a bejegyzéseihez kapcsolódó interakciókat. Üzenet küldése a követett személyeknek Bejelentkezés egy követett személy átjátszóiba a közvetlen üzenetek, válaszok és értesítések küldéséhez. Üzenet küldése bárkinek @@ -803,13 +804,13 @@ Elküldi az üzenetet neki: %1$s? Értesíti őt: %1$s? - Betölti a bejegyzéseket tőle: %1$s? + Profil, bejegyzések és interakciók betöltése tőle: %1$s? Közzéteszi itt: %1$s? Megnyitja ezt: %1$s? Ha nem teszi meg, az üzenete nem lesz kézbesítve neki: %1$s. Ha nem teszi meg, %1$s nem kap erről értesítést. - Ha nem teszi meg, nem fogja itt látni a bejegyzéseket tőle: %1$s. + Ha nem teszi meg, akkor nem fogja itt látni %1$s profilját, bejegyzéseit és interakcióit. Ha nem teszi meg, az üzenete nem lesz közzétéve itt: %1$s. Ha nem teszi meg, nem fogja itt látni ezt: %1$s. %1$s és mások diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 028de5ed97..7470dcfef9 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -811,13 +811,50 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Avtentikacija releja Preverjanje pristnosti (Auth), rele (Relay), podpis (Sign), preverjanje (Verify), NIP-42, identiteta Globalna pravila + V kaj se prijaviti Vedno zahtevaj avtentikacijo Podpiši avtentikacijske izzive za vsak relej, ki to zahteva Nikoli ne avtenticiraj Prezri avtentikacijske izzive vseh relejev + Po meri + Izberite natančno, v katere releje se želite prijaviti. Za vse, česar spodaj niste dovolili, boste vprašani naknadno. + Moji releji in prizorišča + Prijavite se v svoje releje ter v javne klepete, skupnosti in prenose v živo, ki jim sledite ali ste jih dodali med priljubljene. + Preberi objave ljudi, ki jim sledim + Prijavite se v releje oseb, ki jim sledite, za prenos podatkov njihovega profila, objav in odzivov na objave. + Pošlji sporočila osebam, ki jim sledim + Prijavite se v releje oseb, ki jim sledite, da jim lahko pošiljate zasebna sporočila (ZS), odgovore in obvestila. + Pošlji sporočilo vsem + Prijavite se v releje neznanih oseb za pošiljanje zasebnih sporočil, odgovorov in obvestil. Privzeto izklopljeno; vsakič boste vprašani za dovoljenje. + Potrdite temu releju, da ste to vi? + Ta rele želi najprej potrditi, da ste to vi. Upravljavec releja bo videl, kateri račun uporabljate. + Pošljem sporočilo %1$s? + Obvestim %1$s? + Naložim profil, objave in odzive iz %1$s? + Pošljem v %1$s? + Odprem %1$s? + V nasprotnem primeru sporočilo za %1$s ne bo dostavljeno. + V nasprotnem primeru %1$s o tem ne bo obveščen. + V nasprotnem primeru tukaj ne boste videli profila, objav ali odzivov od %1$s. + V nasprotnem primeru vaše sporočilo za %1$s ne bo objavljeno. + V nasprotnem primeru tukaj ne boste videli %1$s. + %1$s in ostali + Pošlji zasebno sporočilo: + Obvesti: + Prenesi podatke o profilu, objave in odzive iz: + Pošlji v to sobo + Odpri to sobo + Uporabi ta rele + Poveži se z lastnim relejem + Dovoli tokrat + Vedno dovoli ta rele + Vedno dostavi moja sporočila + Blokiraj ta rele Prilagoditve po posameznem releju + Pozabi + Zadnjič uporabljen pred %1$s Brez preglasitev na posameznih relejih — globalna politika velja povsod Dovoli Zavrni @@ -855,7 +892,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sporočila ni bilo mogoče poslati Nimate nameščene aplikacije za odpiranje te povezave. Zaprem to sobo? - Vsi udeleženci bodo odklopljeni. Soba bo v viru vsebin prikazana kot ZAPRTA. + Vsi udeleženci bodo odklopljeni. Soba bo v viru vsebin prikazana kot KONČANA. Zapri sobo Napaka pri zvoku: %1$s Govori @@ -1331,7 +1368,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Privzeti seznam sledenih V moji okolici Globalno - Urejeno + Izbrani Moje Seznam utišanih Seznam sledenih @@ -1418,7 +1455,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Označi kot prebrano Novo sporočilo Novi zapi - Odzivi + Odzivne ikone Prejmi obvestila o odzivih na objave %1$s se je odzval na vašo objavo za %1$s @@ -1625,11 +1662,11 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Odpri z drugo aplikacijo Lightning naslov ni nastavljen V ŽIVO - ODKLOPLEN + NEDOSEGLJIV KONČANO PLANIRANO ZASEBNO - Prenos v živo je odklopljen + Prenos v živo je končan Prenos v živo je končan ODPRTO ZASEBNO @@ -1656,7 +1693,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nastavitve aplikacije Nastavitve uporabnika Prevodi - Reakcije + Odzivi Nastavitve Nastavitve računa Nastavitve aplikacije @@ -1791,6 +1828,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Strni skupine posameznega releja v eno vrstico, prikazano pri njegovem najnovejšem sporočilu. Prikaz skupin NIP-29 Skupine relejev + Skupine Sporočila Ustvari skupino Ta relej ne oglašuje podpore za skupine NIP-29. Skupina, ustvarjena tukaj, ne bo delovala – relej ne bo upravljal njenega imena, članov in sporočil. Izberite rele, ki podpira skupine na ravni releja. @@ -1860,6 +1898,41 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nov niz objav Brez naslova Naslov + Napiši kaj… + Pošlji + Najdi skupine + Brskajte po skupinah, ki jih gosti rele. Prilepite naslov releja ali izberite enega spodaj. + Naslov releja + Brskaj + Releji na katerih ste vi + Popularni releji + Ni sporočil + Pridružite se tej skupini, da boste lahko pošiljali sporočila. + Ta skupina je zaprtega tipa – za objavljanje potrebujete povabilo. + + %1$d član + %1$d člana + %1$d člani + %1$d članov + + + %1$d sporočilo + %1$d sporočila + %1$d sporočila + %1$d sporočil + + + %1$d+ sporočilo + %1$d+ sporočila + %1$d+ sporočila + %1$d+ sporočil + + + %1$d uporabnik, ki mu sledite + %1$d uporabnika, ki jima sledite + %1$d uporabniki, ki jim sledite + %1$d uporabnikov, ki jim sledite + Zasebno Za Zadeva @@ -3026,7 +3099,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Posodobitev zahtevka (PR) Ciljni znesek zapov Sledeni ključniki - Osvetlitve + Poudarki Http Auth Indeksiraj seznam relejev Pustolovski prolog @@ -3067,7 +3140,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Zasebni releji Proxy releji Javno sporočilo - Odzivne ikone + Odzivi Kartica kontakta Avtorizacija releja Odkrivanje relejev @@ -3160,7 +3233,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, profili izhodni seznami Nazadnje viden pred %1$s - Nazadje viden na %1$s (%2$s nazaj) + Nazadje viden %1$s (pred %2$s) Nazadnje viden prav zdaj Nikoli viden @@ -3472,6 +3545,102 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Podpis Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite. Vaš podpis + Dokazilo o delu (PoW) + Težavnost + Pred objavo sporočila, rudari (NIP-13) dokaz o delu (PoW), da jih lahko releji in bralci ločijo od neželene vsebine (spam). Višje vrednosti zahtevajo eksponentno več časa za rudarjenje. Objave se po končanem rudarjenju samodejno objavijo v ozadju. + Ugasni + Težavnost po meri + Nastavite cilj v številu začetnih ničel + Kaj rudarit + Časovno kritični dogodki (avtentikacija relejev, zahteve za zap-e, sporočila denarnice in podpisnika, osnutki, seznami) se nikoli ne rudarijo. + Kratki zapiski in odgovori + Glavno javni vir za vsiljeno vsebino + Komentarji + Odgovori na članke, datoteke in drugo vsebino + Prijave + Releji ocenjujejo poročila glede na njihove stroške + Dolg zapis in poudarki + Članki in poudarki; redko, strošek je zanemarljiv + Glasovna sporočila + Javna glasovna sporočila in odgovori + Deli dalje + Višja glasnost za aktivne uporabnike + Odzivi + Tip (kind) z najvišjim obsegom; rudarjenje vsakega všečka porablja baterijo + Javni in pogovori v živo + Zamik zaradi rudarjenja, moti tekočemu pogovoru + Ovoji zasebnih sporočil + Releji za zasebna sporočila filtrirajo vsiljeno vsebino; rudari se le zunanji ovoj, nikoli vaše sporočilo. + Ostala javna vsebina + Ankete, statusi v živo, mali oglasi in vse ostalo javno + Rudarjenje dokaza o delu (PoW) + + Rudarjenje dokaza o delu… (%1$d objava v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objavi v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objave v čakalni vrsti) + Rudarjenje dokaza o delu… (%1$d objav v čakalni vrsti) + + + %1$s • rudarjenje pri %2$d bitu + %1$s • rudarjenje pri %2$d bitih + %1$s • rudarjenje pri %2$d bitih + %1$s • rudarjenje pri %2$d bitih + + + %1$s • čakanje na rudarjenje pri %2$d bitu + %1$s • čakanje na rudarjenje pri %2$d bitih + %1$s • čakanje na rudarjenje pri %2$d bitih + %1$s • čakanje na rudarjenje pri %2$d bitih + + + Privzeto (%1$d bit) + Privzeto (%1$d bita) + Privzeto (%1$d biti) + Privzeto (%1$d bitov) + + Privzeto (izklopljeno) + Izklopljeno za ta zapisek + + %1$d bit + %1$d bita + %1$d biti + %1$d bitov + + Rudarjenje z dokazom o delu (PoW) + Prikazuje objave, ki še rudarijo svoj NIP-13 dokaz o delu, da lahko zaključijo postopek, potem ko zapustite aplikacijo. + Prekliči + ≈ %1$s na objavo v tej napravi + <1 s + + %1$d sekunda + %1$d sekundi + %1$d sekunde + %1$d sekund + + + %1$d minuta + %1$d minuti + %1$d minut + %1$d minut + + + %1$d ura + %1$d uri + %1$d ure + %1$d ur + + + %1$d dan + %1$d dneva + %1$d dni + %1$d dni + + ≈ %1$s še ostane + Vsak čas bo + Vaš %1$s je zaključil rudarjenje, vendar ga ni bilo mogoče objaviti: %2$s + Vaš %1$s je končal z rudarjenjem, vendar ga ni bilo mogoče objaviti. Ponovni poskus bo izveden ob naslednjem zagonu aplikacije. + Sporočilo klepeta + Prijavi Uporabi to Prekliči Pravilno diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index b56145cbb7..976a4a9801 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -134,6 +134,12 @@ "Hindi" ] }, + { + "user": "StellarStoic", + "languages": [ + "Slovenian" + ] + }, { "user": "summoner001", "languages": [ @@ -146,12 +152,6 @@ "Chinese Simplified" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "anthony-robin", "languages": [ From 025d63672fc667cabcdf902421a1c65f0ad6bf55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:39:57 +0000 Subject: [PATCH 086/206] =?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 087/206] 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 6415515a5e000fb02c4f89a132d2fada45bca098 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:57:35 +0000 Subject: [PATCH 088/206] =?UTF-8?q?feat:=20resource-usage=20ledger=20?= =?UTF-8?q?=E2=80=94=20on-device=20battery/data=20accounting=20+=20NIP-17?= =?UTF-8?q?=20reports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passive, always-on counters (never transmitted automatically) that make battery/data complaints diagnosable in production, where they actually happen — see amethyst/plans/2026-07-12-resource-usage-ledger.md: - Per-subsystem HTTP bytes (image/video/uploads/money/nip05/preview/push) via a counting interceptor wrapped per role in RoleBasedHttpClientBuilder, split by cellular/wifi and foreground/background. - Relay traffic (RelayConnectionListener) and relay connection-time (Σ connections x time, integrated from connectedRelaysFlow with no timers) — the ping study's best single battery proxy. - Notification wakelock held-time, background worker runs, and app process starts (WorkManager churn detector). - Daily buckets persisted to one small JSON file (ScheduledPostStore idiom), pruned after 30 days; counters are sizes/durations/counts only, no URLs, relay names, or content. Surfaces: - Settings > "App resource usage": today / 7-day summaries and a data-by-feature breakdown, plus an explicit "send report via DM" button that prefills the NIP-17 composer to the developers (same pipeline, recipient, and 30-day expiration as crash reports — nothing sends until the user taps Send). - High-consumption prompt: on app open, if the last day crossed conservative thresholds (>50 MB background cellular, >12 relay-hours backgrounded on cellular, >30 min notification wakelock, >75 process starts), an AlertDialog offers review-and-send — at most once every 7 days, with a persistent "don't ask again". 14 unit tests cover the store, accountant (day rollover, live-merge, pre-flush hooks), connection-time integrator (incl. the long-stable- session case), and alert thresholds/rate limiting. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 120 +++++++ .../com/vitorpamplona/amethyst/Amethyst.kt | 4 + .../com/vitorpamplona/amethyst/AppModules.kt | 56 +++- .../RoleBasedHttpClientBuilder.kt | 27 +- .../calendar/CalendarReminderWorker.kt | 3 + .../EventNotificationConsumer.kt | 5 + .../NotificationCatchUpWorker.kt | 2 + .../notifications/NotificationDispatcher.kt | 4 +- .../DisplayResourceUsageAlert.kt | 159 ++++++++++ .../resourceusage/ForegroundTracker.kt | 69 ++++ .../RelayConnectionTimeIntegrator.kt | 92 ++++++ .../resourceusage/RelayUsageListener.kt | 58 ++++ .../resourceusage/ResourceUsageAccountant.kt | 120 +++++++ .../resourceusage/ResourceUsageAlerts.kt | 96 ++++++ .../ResourceUsageReportAssembler.kt | 114 +++++++ .../resourceusage/ResourceUsageStore.kt | 154 +++++++++ .../resourceusage/UsageCountingInterceptor.kt | 124 ++++++++ .../service/resourceusage/UsageKeys.kt | 96 ++++++ .../service/resourceusage/UsageSummary.kt | 89 ++++++ .../scheduledposts/ScheduledPostWorker.kt | 2 + .../amethyst/ui/navigation/AppNavigation.kt | 4 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/settings/ResourceUsageScreen.kt | 221 +++++++++++++ .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 39 +++ .../resourceusage/ResourceUsageLedgerTest.kt | 295 ++++++++++++++++++ 26 files changed, 1946 insertions(+), 10 deletions(-) create mode 100644 amethyst/plans/2026-07-12-resource-usage-ledger.md create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md new file mode 100644 index 0000000000..7d460b68bf --- /dev/null +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -0,0 +1,120 @@ +# Resource Usage Ledger — battery/data accounting, user-visible + NIP-17 reportable + +**Date:** 2026-07-12 +**Goal:** Let users (and developers) see how much network, connection time, and +background activity the app consumes, per subsystem — and let a user send that +data to the developers over NIP-17, reusing the crash-report consent pattern. +When consumption crosses "something is wrong" thresholds, proactively ask the +user (rate-limited, opt-out-able) whether they'd like to send a report. + +Background: the 2026-07-12 ping-interval study (see +`2026-07-12-relay-ping-interval-study.md`) showed the dominant energy proxy is +connection-time (relays server-ping every 30–70s while connected) and that +battery bugs are production-only phenomena — so the ledger ships in release, +collects passively, and never transmits anything without an explicit user +action. + +## Survey (existing components reused) + +- **Send path** — the crash-report pipeline: `DisplayCrashMessages` prefills + the NIP-17 DM composer via `routeToMessage(user = , draftMessage, + expiresDays = 30)`; the user taps Send; `Account.sendNip17PrivateMessage` + gift-wraps to the recipient's kind-10050 DM relays. Reused as-is — the + ledger only builds a different draft string. +- **Persistence idiom** — `ScheduledPostStore` (Jackson + Mutex + tmp-rename + + version envelope + StateFlow). Cloned as `ResourceUsageStore`. +- **Relay traffic** — counted by a new `RelayConnectionListener` + (same hook `RelayStats` uses), NOT by modifying quartz. +- **Connection time** — integrated from `INostrClient.connectedRelaysFlow()` + (exact between emissions; no timers). +- **Network class** — `ConnectivityManager.isMobileOrFalse` StateFlow. +- **Foreground** — new tiny `ForegroundTracker` (ActivityLifecycleCallbacks → + StateFlow), registered next to `AppForegroundRecycleHook`; + `MainActivity.isResumed` is not observable and slightly stricter than + process-foreground. +- **HTTP subsystems** — `RoleBasedHttpClientBuilder` already funnels every + role (image/video/uploads/money/nip05/preview/push) through two shared + clients; a cached per-role `newBuilder().addInterceptor(counting)` wrapper + gives per-subsystem byte attribution without touching the shared clients. +- **UI idioms** — `NotificationSettingsScreen` structure (`Scaffold` + + `TopBarWithBackButton` + `SettingsSection` cards), route in `Routes.kt`, + `composableFromEnd` registration, catalog entry via + `SettingsCatalogBuilder.symEntry` (icon: existing `MaterialSymbols.Bolt` — + no font regen). +- **App-open dialog** — `DisplayCrashMessages` pattern, mounted in the same + `AppNavigation` block. + +## Design + +### Counters +Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: +`....[.]`, e.g.: + +- `net.image.mobile.bg.rx` — bytes downloaded by the image subsystem on + cellular while backgrounded (same for video/uploads/money/nip05/preview/push) +- `relay.msg.wifi.fg.rx|tx` — approx relay websocket payload bytes +- `relay.connms.mobile.bg` — relay-connection-milliseconds (Σ relays × time) +- `wakelock.notif.ms` / `wakelock.notif.count` +- `worker.scheduledPost.runs` / `worker.calendarReminder.runs` / + `worker.notificationCatchUp.runs` +- `app.starts` — process starts (detects WorkManager cold-start churn) + +Flat keys keep the store schema-free: new counters need no migration. + +### Components (`amethyst/.../service/resourceusage/`) +- `UsageKeys` — key constants/builders + dimension helpers. +- `ResourceUsageStore` — daily buckets on disk (`resource_usage.json`), + `mergeInto(day, deltas)`, `allDays()`, prune, plus alert state + (lastAlertAtSec, optOut). +- `ResourceUsageAccountant` — in-memory `ConcurrentHashMap` + hot path (`add()` is called per relay frame), debounced flush (30s) into the + store, day-rollover handling, merged read API for UI/report. +- `ForegroundTracker` — startedActivities>0 as StateFlow. +- `RelayUsageListener` — `RelayConnectionListener` counting sent/received + frame sizes with current network/visibility dims. +- `RelayConnectionTimeIntegrator` — combines connectedRelays × isMobile × + isForeground; closes an accounting segment on every change and on + `closeOpenSegment()` (called from accountant flush and reads, so multi-hour + stable background sessions still account without any timer). +- `UsageCountingInterceptor` + counting response body — per-role HTTP bytes; + wrapped clients cached per (role, base client identity). +- `ResourceUsageReportAssembler` — Markdown: device/app header (crash-report + style), human summary (today + 7 days), fenced per-day counter dump. +- `ResourceUsageAlerts` — pure threshold logic (see below) + rate limiting. +- `DisplayResourceUsageAlert` — consent dialog (view details / send / not + now / don't ask again). +- UI: `ResourceUsageScreen` under `ui/screen/loggedIn/settings/`. + +### Wiring (AppModules / Amethyst / hooks) +- store + accountant + integrator constructed in `AppModules`; listener added + via `client.addConnectionListener`. +- `ForegroundTracker` registered in `Amethyst.onCreate` (main process only). +- `RoleBasedHttpClientBuilder` gains an optional usage meter. +- `EventNotificationConsumer.withWakeLock` gains an optional held-duration + callback (threaded through `NotificationDispatcher`). +- Workers increment their run counters via `Amethyst.instance` (guarded). +- `AppModules.trim()` flushes the accountant (backgrounding = natural flush). + +### Alert thresholds (v1, deliberately conservative — tune with real reports) +Evaluated on the last *complete* day, OR today once exceeded: +- background cellular traffic > 50 MB/day +- relay connection time > 12 relay-hours/day while backgrounded on cellular +- notification wakelock held > 30 min/day +- process starts > 75/day +Rate limit: at most one prompt per 7 days; "don't ask again" persisted. +Never auto-sends: every path goes through the DM composer where the user sees +exactly what will be sent and must tap Send. + +### Privacy +Counters are sizes, durations, and counts — no URLs, no relay names, no event +content. The report includes device model fields identical to the crash +report. Everything stays on-device until the user explicitly sends the DM +(NIP-40 30-day expiration, same as crash reports). + +### Explicitly out of scope (v1) +- Layer 1 (Perfetto/ODPM macrobenchmarks) and Layer 2 (`TrafficStats` socket + tags) — add only if the ledger proves blind somewhere (e.g. WS bytes are + payload-approximate; TrafficStats would give exact on-wire bytes). +- Per-relay attribution in the ledger (RelayStats screens already exist). +- Desktop: accountant/store are Android-module for now; extraction to commons + is mechanical if desktop wants it. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 31b4ea27e8..c615047be8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -110,6 +110,10 @@ class Amethyst : Application() { // kdoc for the threshold rationale. registerActivityLifecycleCallbacks(AppForegroundRecycleHook()) + // Foreground signal for the resource-usage ledger (fg/bg attribution + // of bytes and connection-time). Main process only. + registerActivityLifecycleCallbacks(instance.foregroundTracker) + if (isDebug) { Logging.setup() // Auto-enable the Nests session-trace recorder in debug diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0d1a92ff48..bb2f1700f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -86,6 +86,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator +import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate @@ -279,6 +286,24 @@ class AppModules( // on Tor-enabled clients to transparently redirect to .onion addresses. val onionLocationCache = OnionLocationCache() + // ---- Resource-usage ledger (battery/data accounting) ---- + // Passive on-device counters (bytes per subsystem x network x visibility, + // relay connection-time, wakelock time, worker runs). Never transmitted; + // the user can review them in Settings and explicitly DM a report to the + // developers. See amethyst/plans/2026-07-12-resource-usage-ledger.md. + val foregroundTracker = ForegroundTracker() + + val resourceUsageStore = ResourceUsageStore(File(appContext.filesDir, ResourceUsageStore.FILE_NAME)) + + val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope) + + private val httpUsageMeter = + HttpUsageMeter( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ) + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -301,7 +326,7 @@ class AppModules( ) // Offers easy methods to know when connections are happening through Tor or not - val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value) + val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value, httpUsageMeter) val electrumXClient by lazy { Log.d("AppModules", "ElectrumXClient Init") @@ -605,6 +630,23 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) + // Resource-usage ledger: relay traffic + connection-time collectors. + init { + client.addConnectionListener( + RelayUsageListener( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + ), + ) + RelayConnectionTimeIntegrator( + connectedCount = client.connectedRelaysFlow().map { it.size }, + isMobile = connManager.isMobileOrNull, + isForeground = foregroundTracker.isForeground, + accountant = resourceUsage, + ).start(applicationIOScope) + } + // Logs debug messages when needed val detailedLogger = if (isDebug) RelayLogger(client, debugSending = false, debugReceiving = false) else null val relayReqStats = if (isDebug) RelayReqStats(client) else null @@ -740,7 +782,11 @@ class AppModules( // Observes LocalCache for notification-relevant events and routes them to // EventNotificationConsumer. Sources: FCM, UnifiedPush, Pokey, active relay // subscriptions, and NotificationRelayService. - val notificationDispatcher = NotificationDispatcher(appContext, applicationIOScope) + val notificationDispatcher = + NotificationDispatcher(appContext, applicationIOScope) { heldMs -> + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_MS, heldMs) + resourceUsage.add(UsageKeys.WAKELOCK_NOTIF_COUNT, 1) + } // Local store for posts the user has scheduled to publish later. Backed by a // single JSON file under the app's private filesDir; read by ScheduledPostWorker. @@ -824,6 +870,10 @@ class AppModules( fun initiate(appContext: Context) { Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope)) + // Ledger: count process starts — high counts reveal WorkManager/restart + // churn that cold-starts the whole app graph repeatedly. + resourceUsage.add(UsageKeys.APP_STARTS, 1) + // Restore the persisted DNS cache before any networking starts. Lookups that fire // before this completes fall through to the sync resolver path (existing behavior); // once restored, every previously-seen host hits the stale-while-revalidate path @@ -1005,6 +1055,8 @@ class AppModules( fun trim(level: Int) { _trimLevelEvents.tryEmit(level) + // Backgrounding is a natural moment to flush the usage ledger too. + resourceUsage.flushAsync() applicationIOScope.launch { // Backgrounding is a natural moment to flush the DNS cache. dnsStore.save() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index a332044be6..b2ca4dcafc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.privacyOptions import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager +import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient @@ -32,7 +34,18 @@ import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, val torSettings: TorSettingsFlow, + /** + * When present, every role's client is wrapped with a byte-counting + * interceptor so the resource-usage ledger can attribute HTTP traffic + * per subsystem. Null keeps the raw shared clients (tests). + */ + val usageMeter: HttpUsageMeter? = null, ) : IRoleBasedHttpClientBuilder { + private fun metered( + role: String, + base: OkHttpClient, + ): OkHttpClient = usageMeter?.counted(role, base) ?: base + fun shouldUseTorForImageDownload(url: String) = shouldUseTorFor( url, @@ -131,19 +144,19 @@ class RoleBasedHttpClientBuilder( override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) - override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url)) + override fun okHttpClientForNip05(url: String): OkHttpClient = metered(UsageKeys.ROLE_NIP05, okHttpClient.getHttpClient(shouldUseTorForNIP05(url))) - override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url)) + override fun okHttpClientForUploads(url: String): OkHttpClient = metered(UsageKeys.ROLE_UPLOADS, okHttpClient.getHttpClient(shouldUseTorForUploads(url))) - override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url)) + override fun okHttpClientForImage(url: String): OkHttpClient = metered(UsageKeys.ROLE_IMAGE, okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))) - override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url)) + override fun okHttpClientForVideo(url: String): OkHttpClient = metered(UsageKeys.ROLE_VIDEO, okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))) - override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url)) + override fun okHttpClientForMoney(url: String): OkHttpClient = metered(UsageKeys.ROLE_MONEY, okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))) - override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) + override fun okHttpClientForPreview(url: String): OkHttpClient = metered(UsageKeys.ROLE_PREVIEW, okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))) - override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + override fun okHttpClientForPushRegistration(url: String): OkHttpClient = metered(UsageKeys.ROLE_PUSH, okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())) /** * Returns a [SocketFactory] that routes through the user's Tor proxy diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index a68089408f..60453031bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -26,9 +26,11 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent @@ -60,6 +62,7 @@ class CalendarReminderWorker( params: WorkerParameters, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("calendarReminder"), 1) } val prefs = CalendarReminderPrefs(applicationContext) if (!prefs.isEnabled()) { Log.d(TAG) { "Reminders disabled; ending periodic chain." } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 8b048326b9..123a03e162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -24,6 +24,7 @@ import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable import android.os.PowerManager +import android.os.SystemClock import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.asDrawable @@ -108,6 +109,8 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" class EventNotificationConsumer( private val applicationContext: Context, + /** Reports how long each notification-processing wakelock was held (resource-usage ledger). */ + private val onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes @@ -126,6 +129,7 @@ class EventNotificationConsumer( PowerManager.PARTIAL_WAKE_LOCK, "amethyst:notification_processing", ) + val heldSince = SystemClock.elapsedRealtime() wakeLock.acquire(WAKELOCK_TIMEOUT_MS) try { return block() @@ -133,6 +137,7 @@ class EventNotificationConsumer( if (wakeLock.isHeld) { wakeLock.release() } + onWakeLockHeld?.invoke(SystemClock.elapsedRealtime() - heldSince) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt index 610e57a859..d11a88bd00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -120,6 +121,7 @@ class NotificationCatchUpWorker( override suspend fun doWork(): Result { Log.d(TAG, "Starting notification catch-up") + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("notificationCatchUp"), 1) } return try { // If the foreground service should be running but isn't, restart it diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index 37e6fb2f96..1f583ded83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -80,6 +80,8 @@ import kotlinx.coroutines.launch class NotificationDispatcher( private val context: Context, private val scope: CoroutineScope, + /** Forwarded to [EventNotificationConsumer]: reports wakelock held-time to the resource-usage ledger. */ + onWakeLockHeld: ((heldMs: Long) -> Unit)? = null, ) { companion object { private const val TAG = "NotificationDispatcher" @@ -127,7 +129,7 @@ class NotificationDispatcher( ) } - private val consumer = EventNotificationConsumer(context) + private val consumer = EventNotificationConsumer(context, onWakeLockHeld) private var job: Job? = null fun start() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt new file mode 100644 index 0000000000..f51ec4e2f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -0,0 +1,159 @@ +/* + * 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.service.resourceusage + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.res.pluralStringResource +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Developer recipient for user-initiated diagnostic DMs — the same account + * the crash-report dialog routes to. + */ +const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" + +/** + * On app open, checks the resource-usage ledger against the + * [ResourceUsageAlerts] thresholds and — at most once per week, unless the + * user opted out — asks whether they'd like to review + send a usage report + * to the developers. Confirming only PREFILLS the NIP-17 DM composer (crash + * report pattern): the full report text is visible there and nothing is sent + * until the user taps Send. + */ +@Composable +fun DisplayResourceUsageAlert( + accountViewModel: AccountViewModel, + nav: INav, +) { + val alert = remember { mutableStateOf(null) } + + LaunchedEffect(accountViewModel) { + withContext(Dispatchers.IO) { + val store = Amethyst.instance.resourceUsageStore + val accountant = Amethyst.instance.resourceUsage + if (!ResourceUsageAlerts.shouldPrompt(store.lastAlertAtSec(), store.alertsOptOut(), TimeUtils.now())) { + return@withContext + } + val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today()) + if (found != null) { + // Mark immediately so process restarts can't re-prompt within the window. + store.markAlertPrompted(TimeUtils.now()) + alert.value = found + } + } + } + + alert.value?.let { found -> + AlertDialog( + onDismissRequest = { alert.value = null }, + title = { Text(stringRes(R.string.resource_usage_alert_title)) }, + text = { + Text( + stringRes( + R.string.resource_usage_alert_message, + reasonDescription(found), + ), + ) + }, + dismissButton = { + Row { + TextButton(onClick = { + accountViewModel.runOnIO { + Amethyst.instance.resourceUsageStore.setAlertsOptOut(true) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_opt_out)) + } + TextButton(onClick = { alert.value = null }) { + Text(stringRes(R.string.resource_usage_alert_not_now)) + } + } + }, + confirmButton = { + Button(onClick = { + nav.nav { + val report = + ResourceUsageReportAssembler().buildReport( + Amethyst.instance.resourceUsage.allDaysIncludingLive(), + Amethyst.instance.resourceUsage.today(), + ) + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + alert.value = null + }) { + Text(stringRes(R.string.resource_usage_alert_send)) + } + }, + ) + } +} + +@Composable +private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String = + when (alert.reason) { + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA -> + stringRes( + R.string.resource_usage_reason_bg_data, + ResourceUsageReportAssembler.formatBytes(alert.value), + ) + + ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME -> + stringRes( + R.string.resource_usage_reason_conn_time, + ResourceUsageReportAssembler.formatConnHours(alert.value), + ) + + ResourceUsageAlerts.Reason.WAKELOCK_TIME -> + stringRes( + R.string.resource_usage_reason_wakelock, + ResourceUsageReportAssembler.formatDurationMs(alert.value), + ) + + ResourceUsageAlerts.Reason.PROCESS_CHURN -> + pluralStringResource( + R.plurals.resource_usage_reason_churn, + alert.value.toInt(), + alert.value.toInt(), + ) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.kt new file mode 100644 index 0000000000..0c4796a6a0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTracker.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.amethyst.service.resourceusage + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Process-level foreground signal as an observable StateFlow: true while at + * least one activity is STARTED. Used by the usage ledger to attribute bytes + * and connection-time to foreground vs background buckets. + * + * ([MainActivity.isResumed] is not observable and goes false during PiP / + * in-app dialogs, which would misattribute foreground traffic to background.) + */ +class ForegroundTracker : Application.ActivityLifecycleCallbacks { + private var startedActivities = 0 + + private val _isForeground = MutableStateFlow(false) + val isForeground: StateFlow = _isForeground.asStateFlow() + + override fun onActivityStarted(activity: Activity) { + startedActivities++ + _isForeground.value = startedActivities > 0 + } + + override fun onActivityStopped(activity: Activity) { + startedActivities = (startedActivities - 1).coerceAtLeast(0) + _isForeground.value = startedActivities > 0 + } + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle?, + ) {} + + override fun onActivityResumed(activity: Activity) {} + + override fun onActivityPaused(activity: Activity) {} + + override fun onActivitySaveInstanceState( + activity: Activity, + outState: Bundle, + ) {} + + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt new file mode 100644 index 0000000000..7fea7880eb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt @@ -0,0 +1,92 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates relay-connection-time (Σ open connections × elapsed time) into + * the ledger, split by network class and visibility. Connection-time is the + * best single battery proxy the ping study found: most relays server-ping + * every 30-70s, so the radio is active for as long as connections are open. + * + * No timers: the integral is exact between state changes (the connection + * count is constant), so a segment is closed only when any input changes — + * plus on [closeOpenSegment], which the accountant calls before every flush + * and read so multi-hour stable sessions still account. + */ +class RelayConnectionTimeIntegrator( + private val connectedCount: Flow, + private val isMobile: Flow, + private val isForeground: Flow, + private val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + private data class SegmentState( + val count: Int, + val mobile: Boolean, + val foreground: Boolean, + ) + + private val lock = Any() + private var current: SegmentState? = null + private var segmentStartMs: Long = 0L + + fun start(scope: CoroutineScope): Job { + accountant.addPreFlushHook(::closeOpenSegment) + return scope.launch { + combine(connectedCount, isMobile, isForeground) { count, mobile, fg -> + SegmentState(count, mobile ?: false, fg) + }.collect { next -> transitionTo(next) } + } + } + + /** Accounts the running segment up to now without changing state. */ + fun closeOpenSegment() { + synchronized(lock) { + val state = current ?: return + val now = nowMs() + account(state, now - segmentStartMs) + segmentStartMs = now + } + } + + private fun transitionTo(next: SegmentState) { + synchronized(lock) { + val now = nowMs() + current?.let { account(it, now - segmentStartMs) } + current = next + segmentStartMs = now + } + } + + private fun account( + state: SegmentState, + elapsedMs: Long, + ) { + if (state.count <= 0 || elapsedMs <= 0) return + accountant.add(UsageKeys.relayConnMs(state.mobile, state.foreground), state.count * elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt new file mode 100644 index 0000000000..190649036f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.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.amethyst.service.resourceusage + +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.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command + +/** + * Counts relay websocket traffic into the usage ledger. Frame sizes are + * UTF-16 char counts of the JSON payload — a close proxy for on-wire bytes + * (relay JSON is ASCII-dominant), consistent with how RelayStats counts. + * Excludes WS framing/compression; good enough for "which subsystem is + * eating my data plan" comparisons. + */ +class RelayUsageListener( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (success) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong()) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt new file mode 100644 index 0000000000..0b19a1bf1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -0,0 +1,120 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.LongAdder + +/** + * In-memory hot path for usage counters. [add] is called from network threads + * per relay frame / HTTP response chunk, so it must be allocation-light and + * lock-free: a ConcurrentHashMap of LongAdders, drained into + * [ResourceUsageStore] by a debounced flush (at most one write per + * [flushDebounceMs] while traffic flows; nothing scheduled when idle). + * + * Day attribution: deltas are drained into the bucket of the day they are + * drained on. Counts within one debounce window of midnight may land on the + * neighboring day — irrelevant at the ledger's day-level granularity. + */ +class ResourceUsageAccountant( + private val store: ResourceUsageStore, + private val scope: CoroutineScope, + private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS }, + private val flushDebounceMs: Long = 30_000L, +) { + private val live = ConcurrentHashMap() + private val flushScheduled = AtomicBoolean(false) + + /** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */ + private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>() + + fun addPreFlushHook(hook: () -> Unit) { + preFlushHooks.add(hook) + } + + fun add( + key: String, + amount: Long, + ) { + if (amount <= 0) return + live.computeIfAbsent(key) { LongAdder() }.add(amount) + if (flushScheduled.compareAndSet(false, true)) { + scope.launch { + delay(flushDebounceMs) + flushScheduled.set(false) + flush() + } + } + } + + /** Drains the in-memory counters into today's persisted bucket. */ + suspend fun flush() { + preFlushHooks.forEach { runCatching { it() } } + val deltas = drain() + if (deltas.isNotEmpty()) { + store.mergeInto(epochDay(), deltas) + } + } + + /** Fire-and-forget flush for non-suspending callers (onTrimMemory). */ + fun flushAsync() { + scope.launch { flush() } + } + + fun today(): Long = epochDay() + + /** + * Persisted buckets merged with the not-yet-flushed live counters + * (attributed to today). This is what the UI, the report assembler, + * and the alert evaluator read. + */ + suspend fun allDaysIncludingLive(): Map> { + preFlushHooks.forEach { runCatching { it() } } + val persisted = store.allDays() + val liveSnapshot = live.mapValues { it.value.sum() }.filterValues { it > 0 } + if (liveSnapshot.isEmpty()) return persisted + val today = epochDay() + val merged = persisted.toMutableMap() + val todayBucket = merged[today].orEmpty().toMutableMap() + liveSnapshot.forEach { (key, value) -> todayBucket[key] = (todayBucket[key] ?: 0L) + value } + merged[today] = todayBucket + return merged + } + + private fun drain(): Map { + if (live.isEmpty()) return emptyMap() + val deltas = mutableMapOf() + for (key in live.keys) { + val adder = live.remove(key) ?: continue + val value = adder.sum() + if (value > 0) deltas[key] = value + } + return deltas + } + + companion object { + const val DAY_MS = 24L * 60L * 60L * 1000L + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt new file mode 100644 index 0000000000..4180ef78cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt @@ -0,0 +1,96 @@ +/* + * 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.service.resourceusage + +/** + * Pure threshold logic for the "this app is consuming too much" prompt. + * Thresholds are deliberately conservative: the prompt should fire for the + * pathological cases (a stuck reconnect loop, process-restart churn, runaway + * background sync) — not for a heavy day of normal use. Tune them as real + * reports come in. + * + * Never auto-sends anything: a positive evaluation only ASKS the user, at + * most once every [MIN_DAYS_BETWEEN_PROMPTS] days, and respects a permanent + * opt-out. Both are persisted in [ResourceUsageStore]. + */ +object ResourceUsageAlerts { + enum class Reason { + BACKGROUND_MOBILE_DATA, + BACKGROUND_MOBILE_CONNECTION_TIME, + WAKELOCK_TIME, + PROCESS_CHURN, + } + + data class Alert( + val reason: Reason, + val day: Long, + val value: Long, + ) + + /** > 50 MB of background traffic on cellular in one day. */ + const val BG_MOBILE_BYTES_PER_DAY = 50L * 1024L * 1024L + + /** > 12 relay-connection-hours while backgrounded on cellular in one day. */ + const val BG_MOBILE_RELAY_CONN_MS_PER_DAY = 12L * 60L * 60L * 1000L + + /** > 30 minutes of notification wakelock held in one day. */ + const val WAKELOCK_MS_PER_DAY = 30L * 60L * 1000L + + /** > 75 process starts in one day (WorkManager/restart churn). */ + const val APP_STARTS_PER_DAY = 75L + + const val MIN_DAYS_BETWEEN_PROMPTS = 7L + + /** + * Checks yesterday (the last complete day) first, then today (so a + * runaway condition surfaces without waiting for midnight). Returns the + * first threshold crossed or null. + */ + fun evaluate( + days: Map>, + today: Long, + ): Alert? { + for (day in longArrayOf(today - 1, today)) { + val counters = days[day] ?: continue + val summary = UsageSummary.from(counters) + + if (summary.mobileBytesBg > BG_MOBILE_BYTES_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_DATA, day, summary.mobileBytesBg) + } + if (summary.relayConnMsMobileBg > BG_MOBILE_RELAY_CONN_MS_PER_DAY) { + return Alert(Reason.BACKGROUND_MOBILE_CONNECTION_TIME, day, summary.relayConnMsMobileBg) + } + if (summary.wakelockMs > WAKELOCK_MS_PER_DAY) { + return Alert(Reason.WAKELOCK_TIME, day, summary.wakelockMs) + } + if (summary.appStarts > APP_STARTS_PER_DAY) { + return Alert(Reason.PROCESS_CHURN, day, summary.appStarts) + } + } + return null + } + + fun shouldPrompt( + lastAlertAtSec: Long, + optOut: Boolean, + nowSec: Long, + ): Boolean = !optOut && nowSec - lastAlertAtSec >= MIN_DAYS_BETWEEN_PROMPTS * 24L * 60L * 60L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt new file mode 100644 index 0000000000..47c8493b8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.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.service.resourceusage + +import android.os.Build +import com.vitorpamplona.amethyst.BuildConfig +import java.util.Locale + +/** + * Assembles the Markdown resource-usage report the user can DM to the + * developers via NIP-17 — same shape as the crash ReportAssembler: a device + * header table, a human-readable summary, then the full per-day counter dump + * as the technical payload. Counters are sizes/durations/counts only; no + * URLs, relay names, or content. + */ +class ResourceUsageReportAssembler { + fun buildReport( + days: Map>, + today: Long, + ): String { + val sb = StringBuilder() + sb.append("Resource Usage Report: ") + sb.append(BuildConfig.VERSION_NAME) + sb.append("-") + sb.append(BuildConfig.FLAVOR.uppercase()) + sb.append("\n\n") + + sb.append("| Prop | Value |\n") + sb.append("| --- | --- |\n") + sb.append("| Manuf | ${Build.MANUFACTURER} |\n") + sb.append("| Model | ${Build.MODEL} |\n") + sb.append("| Android | ${Build.VERSION.RELEASE} |\n") + sb.append("| SDK Int | ${Build.VERSION.SDK_INT} |\n") + sb.append("\n") + + val todayCounters = days[today].orEmpty() + val weekCounters = (today - 6..today).mapNotNull { days[it] } + + sb.append("**Today**\n\n") + sb.append(summaryTable(UsageSummary.from(todayCounters))) + sb.append("\n**Last 7 days**\n\n") + sb.append(summaryTable(UsageSummary.fromDays(weekCounters))) + + sb.append("\nTechnical details (per epoch-day):\n") + sb.append("```\n") + days.toSortedMap().forEach { (day, counters) -> + sb.append("day $day (today=$today)\n") + counters.toSortedMap().forEach { (key, value) -> + sb.append(" $key = $value\n") + } + } + sb.append("```\n") + return sb.toString() + } + + private fun summaryTable(s: UsageSummary): String = + buildString { + append("| Metric | Value |\n") + append("| --- | --- |\n") + append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n") + append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n") + append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n") + append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n") + append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") + append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") + append("| Background worker runs | ${s.workerRuns} |\n") + append("| App process starts | ${s.appStarts} |\n") + val subsystems = + s.bytesPerSubsystem.entries + .sortedByDescending { it.value } + .joinToString(", ") { "${it.key} ${formatBytes(it.value)}" } + if (subsystems.isNotEmpty()) { + append("| By subsystem | $subsystems |\n") + } + } + + companion object { + fun formatBytes(bytes: Long): String = + when { + bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0)) + bytes >= 1024L * 1024L -> String.format(Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0)) + bytes >= 1024L -> String.format(Locale.US, "%.1f KB", bytes / 1024.0) + else -> "$bytes B" + } + + /** Relay-connection time: Σ connections x time, so shown as "relay-hours". */ + fun formatConnHours(ms: Long): String = String.format(Locale.US, "%.1f relay-hours", ms / (1000.0 * 60.0 * 60.0)) + + fun formatDurationMs(ms: Long): String = + when { + ms >= 60L * 60L * 1000L -> String.format(Locale.US, "%.1f h", ms / (1000.0 * 60.0 * 60.0)) + ms >= 60L * 1000L -> String.format(Locale.US, "%.1f min", ms / (1000.0 * 60.0)) + else -> String.format(Locale.US, "%.1f s", ms / 1000.0) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt new file mode 100644 index 0000000000..7e7824631b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -0,0 +1,154 @@ +/* + * 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.service.resourceusage + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.io.File + +/** + * Durable daily buckets for the resource-usage ledger, one JSON file in the + * app's private filesDir. Same persistence idiom as ScheduledPostStore: + * Jackson + Mutex + write-to-tmp-then-rename + version envelope. + * + * Day keys are UTC epoch-days (stringified for JSON). Buckets older than + * [keepDays] are pruned on every merge, so the file stays small (a few KB). + * Also carries the high-consumption alert state (last prompt time, opt-out) + * so the whole feature has exactly one file. + */ +class ResourceUsageStore( + private val storageFile: File, + private val keepDays: Long = 30, +) { + data class UsageFile( + val version: Int = 1, + val days: Map> = emptyMap(), + val lastAlertAtSec: Long = 0L, + val alertsOptOut: Boolean = false, + ) + + private val mapper = + jacksonObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + private val mutex = Mutex() + private var loaded = false + private var data = UsageFile() + + suspend fun mergeInto( + day: Long, + deltas: Map, + ) { + if (deltas.isEmpty()) return + mutex.withLock { + ensureLoaded() + val dayKey = day.toString() + val bucket = data.days[dayKey].orEmpty().toMutableMap() + deltas.forEach { (key, amount) -> bucket[key] = (bucket[key] ?: 0L) + amount } + val pruned = + data.days + .filterKeys { (it.toLongOrNull() ?: Long.MAX_VALUE) >= day - keepDays } + .toMutableMap() + pruned[dayKey] = bucket + data = data.copy(days = pruned) + persist() + } + } + + /** All persisted daily buckets, keyed by epoch-day. */ + suspend fun allDays(): Map> = + mutex.withLock { + ensureLoaded() + data.days.mapNotNull { (k, v) -> k.toLongOrNull()?.let { it to v } }.toMap() + } + + suspend fun lastAlertAtSec(): Long = + mutex.withLock { + ensureLoaded() + data.lastAlertAtSec + } + + suspend fun alertsOptOut(): Boolean = + mutex.withLock { + ensureLoaded() + data.alertsOptOut + } + + suspend fun markAlertPrompted(atSec: Long) = + mutex.withLock { + ensureLoaded() + data = data.copy(lastAlertAtSec = atSec) + persist() + } + + suspend fun setAlertsOptOut(optOut: Boolean) = + mutex.withLock { + ensureLoaded() + data = data.copy(alertsOptOut = optOut) + persist() + } + + private fun ensureLoaded() { + if (loaded) return + data = + try { + if (storageFile.exists() && storageFile.length() > 0) { + mapper.readValue(storageFile) + } else { + UsageFile() + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load resource usage from $storageFile", e) + UsageFile() + } + loaded = true + } + + private fun persist() { + storageFile.parentFile?.mkdirs() + val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") + try { + mapper.writeValue(tmp, data) + if (!tmp.renameTo(storageFile)) { + if (!storageFile.delete() || !tmp.renameTo(storageFile)) { + Log.e(TAG) { "Failed to rename $tmp to $storageFile" } + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Failed to persist resource usage to $storageFile", e) + if (!tmp.delete()) { + Log.w(TAG) { "Failed to clean up temp file $tmp" } + } + } + } + + companion object { + private const val TAG = "ResourceUsageStore" + const val FILE_NAME = "resource_usage.json" + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt new file mode 100644 index 0000000000..296b038e86 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.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.amethyst.service.resourceusage + +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.ForwardingSource +import okio.Source +import okio.buffer +import java.util.concurrent.ConcurrentHashMap + +/** + * Application interceptor that attributes HTTP traffic to a ledger subsystem + * (image/video/uploads/money/nip05/preview/push). Upload size comes from the + * request body's contentLength; download size is counted as the app actually + * consumes the streamed response body, so partially-read streams (video seek, + * cancelled image loads) count only what crossed the wire to the app. + * + * Network/visibility dims are sampled when bytes flow, not when the request + * is created — a request issued on wifi that finishes on cellular counts as + * cellular, matching what the radio actually did. + */ +class UsageCountingInterceptor( + private val role: String, + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L + if (requestBytes > 0) { + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes) + } + + val response = chain.proceed(request) + val body = response.body + return response + .newBuilder() + .body( + CountingResponseBody(body) { bytes -> + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) + }, + ).build() + } + + private class CountingResponseBody( + private val delegate: ResponseBody, + private val onBytes: (Long) -> Unit, + ) : ResponseBody() { + override fun contentType() = delegate.contentType() + + override fun contentLength() = delegate.contentLength() + + private val countedSource by lazy { + object : ForwardingSource(delegate.source() as Source) { + override fun read( + sink: Buffer, + byteCount: Long, + ): Long { + val read = super.read(sink, byteCount) + if (read > 0) onBytes(read) + return read + } + }.buffer() + } + + override fun source() = countedSource + } +} + +/** + * Caches per-(role, base client) wrapped OkHttp clients so each role gets its + * counting interceptor without rebuilding the shared clients. Base clients + * are rebuilt on proxy/network changes (new identity), so the cache is + * cleared when it grows past a small bound. + */ +class HttpUsageMeter( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, +) { + private val wrapped = ConcurrentHashMap, OkHttpClient>() + + fun counted( + role: String, + base: OkHttpClient, + ): OkHttpClient { + if (wrapped.size > MAX_CACHED) wrapped.clear() + return wrapped.getOrPut(role to base) { + base + .newBuilder() + .addInterceptor(UsageCountingInterceptor(role, accountant, isMobile, isForeground)) + .build() + } + } + + companion object { + // roles (7) x live base clients (proxy on/off ~2) with headroom for + // network-change rebuilds before the clear kicks in. + private const val MAX_CACHED = 32 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt new file mode 100644 index 0000000000..ef04e0ea55 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -0,0 +1,96 @@ +/* + * 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.service.resourceusage + +/** + * Counter-key grammar for the resource-usage ledger. Keys are flat strings so + * the on-disk store is schema-free — adding a counter never needs a migration. + * + * Dimensions: + * - network: `mobile` (cellular/metered) vs `wifi` (everything else) + * - visibility: `fg` (an activity is started) vs `bg` + * - direction: `rx` (downloaded) vs `tx` (uploaded) + * + * Counters are sizes, durations, and counts only — never URLs, relay names, or + * content. See plans/2026-07-12-resource-usage-ledger.md. + */ +object UsageKeys { + const val MOBILE = "mobile" + const val WIFI = "wifi" + const val FG = "fg" + const val BG = "bg" + const val RX = "rx" + const val TX = "tx" + + /** HTTP subsystems, matching IRoleBasedHttpClientBuilder's roles. */ + const val ROLE_IMAGE = "image" + const val ROLE_VIDEO = "video" + const val ROLE_UPLOADS = "uploads" + const val ROLE_MONEY = "money" + const val ROLE_NIP05 = "nip05" + const val ROLE_PREVIEW = "preview" + const val ROLE_PUSH = "push" + + val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH) + + /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ + fun net( + role: String, + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */ + fun relayMsg( + mobile: Boolean, + foreground: Boolean, + received: Boolean, + ): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}" + + /** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */ + fun relayConnMs( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connms.${dim(mobile, foreground)}" + + /** `worker.scheduledPost.runs` */ + fun workerRuns(worker: String): String = "worker.$worker.runs" + + const val WAKELOCK_NOTIF_MS = "wakelock.notif.ms" + const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count" + const val APP_STARTS = "app.starts" + + fun dim( + mobile: Boolean, + foreground: Boolean, + ): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}" + + /** Sums every counter whose key matches all the given dot-delimited parts. */ + fun Map.sumMatching(vararg parts: String): Long { + var total = 0L + for ((key, value) in this) { + val segments = key.split('.') + if (parts.all { it in segments }) total += value + } + return total + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt new file mode 100644 index 0000000000..1e6d7e6b38 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.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.service.resourceusage + +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys.sumMatching + +/** + * Headline metrics derived from one or more daily counter buckets. Shared by + * the usage screen, the NIP-17 report, and the high-consumption alerts so + * every surface agrees on the numbers. + */ +data class UsageSummary( + val mobileBytesBg: Long, + val mobileBytesFg: Long, + val wifiBytesBg: Long, + val wifiBytesFg: Long, + val relayConnMsMobileBg: Long, + val relayConnMsMobileFg: Long, + val relayConnMsWifiBg: Long, + val relayConnMsWifiFg: Long, + val wakelockMs: Long, + val wakelockCount: Long, + val workerRuns: Long, + val appStarts: Long, + /** total rx+tx bytes per subsystem (net roles + "relay"). */ + val bytesPerSubsystem: Map, +) { + val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg + val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg + val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg + + companion object { + fun from(counters: Map): UsageSummary { + fun traffic( + net: String, + vis: String, + ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) + + val subsystems = mutableMapOf() + for (role in UsageKeys.HTTP_ROLES) { + val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX) + if (bytes > 0) subsystems[role] = bytes + } + val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) + if (relayBytes > 0) subsystems["relay"] = relayBytes + + return UsageSummary( + mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), + mobileBytesFg = traffic(UsageKeys.MOBILE, UsageKeys.FG), + wifiBytesBg = traffic(UsageKeys.WIFI, UsageKeys.BG), + wifiBytesFg = traffic(UsageKeys.WIFI, UsageKeys.FG), + relayConnMsMobileBg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.BG), + relayConnMsMobileFg = counters.sumMatching("connms", UsageKeys.MOBILE, UsageKeys.FG), + relayConnMsWifiBg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.BG), + relayConnMsWifiFg = counters.sumMatching("connms", UsageKeys.WIFI, UsageKeys.FG), + wakelockMs = counters[UsageKeys.WAKELOCK_NOTIF_MS] ?: 0L, + wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L, + workerRuns = counters.sumMatching("worker", "runs"), + appStarts = counters[UsageKeys.APP_STARTS] ?: 0L, + bytesPerSubsystem = subsystems, + ) + } + + /** Merges several day buckets and summarizes the total. */ + fun fromDays(days: Collection>): UsageSummary { + val merged = mutableMapOf() + days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } + return from(merged) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt index 6663d98463..40d3e4fae8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorker.kt @@ -31,6 +31,7 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -132,6 +133,7 @@ class ScheduledPostWorker( override suspend fun doWork(): Result { val nowSec = System.currentTimeMillis() / 1000 Log.d(TAG) { "doWork() ENTER nowSec=$nowSec runAttempt=$runAttemptCount tags=$tags" } + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.workerRuns("scheduledPost"), 1) } return try { val appModules = Amethyst.instance 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..e5e7c77fa2 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 @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages +import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -228,6 +229,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NotificationSettin import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.OtsSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ProfileUiSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ReactionsSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ResourceUsageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScreen @@ -306,6 +308,7 @@ fun AppNavigation( DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) DisplayBroadcastProgress(accountViewModel) ObserveIncomingCalls(accountViewModel) @@ -481,6 +484,7 @@ fun BuildNavigation( composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } composableFromEnd { CallSettingsScreen(accountViewModel, nav) } composableFromEnd { NotificationSettingsScreen(accountViewModel, nav) } + composableFromEnd { ResourceUsageScreen(accountViewModel, nav) } composableFromEnd { ImportFollowListSelectUserScreen(accountViewModel, nav) } composableFromEndArgs { ImportFollowListPickFollowsScreen(it.userHex, accountViewModel, nav) 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..15be5ed14d 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 @@ -422,6 +422,8 @@ sealed class Route { @Serializable object CalendarReminderSettings : Route() + @Serializable object ResourceUsage : Route() + @Serializable object Lists : Route() @Serializable data class MyPeopleListView( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt new file mode 100644 index 0000000000..06324565ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -0,0 +1,221 @@ +/* + * 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.settings + +import androidx.annotation.StringRes +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs +import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * The resource-usage ledger: how much network, relay connection time, and + * background activity the app consumed, per subsystem — with an explicit, + * user-initiated path to DM the numbers to the developers (same NIP-17 flow + * as crash reports). Everything on this screen stays on-device until the + * user sends that DM. + */ +@Composable +fun ResourceUsageScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + var days by remember { mutableStateOf>?>(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + days = Amethyst.instance.resourceUsage.allDaysIncludingLive() + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(id = R.string.resource_usage_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + val loaded = days + if (loaded == null) { + Text( + text = stringRes(R.string.resource_usage_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val today = Amethyst.instance.resourceUsage.today() + val todaySummary = UsageSummary.from(loaded[today].orEmpty()) + val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) + + UsageSummarySection(R.string.resource_usage_today, todaySummary) + UsageSummarySection(R.string.resource_usage_week, weekSummary) + SubsystemSection(weekSummary) + SendReportSection(accountViewModel, nav, loaded, today) + } + } + } +} + +@Composable +private fun UsageSummarySection( + @StringRes title: Int, + s: UsageSummary, +) { + SettingsSection(title) { + MetricRow(R.string.resource_usage_cellular_bg, formatBytes(s.mobileBytesBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_cellular_fg, formatBytes(s.mobileBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + } +} + +@Composable +private fun SubsystemSection(week: UsageSummary) { + if (week.bytesPerSubsystem.isEmpty()) return + SettingsSection(R.string.resource_usage_by_subsystem) { + val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } + rows.forEachIndexed { index, (subsystem, bytes) -> + if (index > 0) SettingsDivider() + MetricRow(subsystemLabel(subsystem), formatBytes(bytes)) + } + } +} + +@StringRes +private fun subsystemLabel(subsystem: String): Int = + when (subsystem) { + "relay" -> R.string.resource_usage_subsystem_relay + "image" -> R.string.resource_usage_subsystem_image + "video" -> R.string.resource_usage_subsystem_video + "uploads" -> R.string.resource_usage_subsystem_uploads + "money" -> R.string.resource_usage_subsystem_money + "nip05" -> R.string.resource_usage_subsystem_nip05 + "preview" -> R.string.resource_usage_subsystem_preview + "push" -> R.string.resource_usage_subsystem_push + else -> R.string.resource_usage_subsystem_other + } + +@Composable +private fun MetricRow( + @StringRes label: Int, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } +} + +@Composable +private fun SendReportSection( + accountViewModel: AccountViewModel, + nav: INav, + days: Map>, + today: Long, +) { + SettingsSection(R.string.resource_usage_send_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_send_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = { + val report = ResourceUsageReportAssembler().buildReport(days, today) + nav.nav { + routeToMessage( + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), + draftMessage = report, + accountViewModel = accountViewModel, + expiresDays = 30, + ) + } + }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_send_button)) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 3ace415fd7..5e85e88c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -103,6 +103,7 @@ fun buildSettingsCatalog( symEntry(R.string.calendar_reminder_settings_title, MaterialSymbols.CalendarMonth, R.string.calendar_reminder_search_keywords, Route.CalendarReminderSettings), symEntry(R.string.ots_explorer_settings, MaterialSymbols.Search, R.string.ots_explorer_search_keywords, Route.OtsSettings), symEntry(R.string.namecoin_settings, MaterialSymbols.Security, R.string.namecoin_search_keywords, Route.NamecoinSettings), + symEntry(R.string.resource_usage_title, MaterialSymbols.Bolt, R.string.resource_usage_search_keywords, Route.ResourceUsage), ), ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..0239d5a241 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3180,6 +3180,45 @@ Crash Report found Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared Send it + + App resource usage + battery data usage consumption power network diagnostics ledger + Today + Last 7 days + Cellular data (background) + Cellular data (in app) + Wi-Fi data + Relay connection time + \u2026 backgrounded on cellular + Notification processing (CPU awake) + Background jobs run + App process starts + Data by feature (7 days) + Relay sync + Images + Video & audio + Uploads + Wallet & zaps + Address verification + Link previews + Push registration + Other + No usage recorded yet. + Share with the developers + If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen. + Send report via DM + High resource usage detected + Amethyst consumed more than expected recently: %1$s. Would you like to send a usage report to the developers in an encrypted DM? You will see the full report before anything is sent. + %1$s of cellular data in the background in one day + %1$s of relay connections while backgrounded on cellular in one day + the CPU was held awake for %1$s processing notifications in one day + + the app process restarted %1$d time in one day + the app process restarted %1$d times in one day + + Review & send + Not now + Don\u2019t ask again This message will disappear in %1$s Select Signer diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt new file mode 100644 index 0000000000..7a9c0cf9c1 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -0,0 +1,295 @@ +/* + * 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.service.resourceusage + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class ResourceUsageStoreTest { + @get:Rule + val temp = TemporaryFolder() + + private lateinit var file: File + + @Before + fun setUp() { + file = File(temp.root, ResourceUsageStore.FILE_NAME) + } + + @Test + fun mergesAndReloadsFromDisk() = + runTest { + val store = ResourceUsageStore(file) + store.mergeInto(100, mapOf("a" to 5L, "b" to 2L)) + store.mergeInto(100, mapOf("a" to 3L)) + store.mergeInto(101, mapOf("a" to 1L)) + + val reloaded = ResourceUsageStore(file).allDays() + assertEquals(8L, reloaded[100]?.get("a")) + assertEquals(2L, reloaded[100]?.get("b")) + assertEquals(1L, reloaded[101]?.get("a")) + } + + @Test + fun prunesBucketsOlderThanKeepDays() = + runTest { + val store = ResourceUsageStore(file, keepDays = 7) + store.mergeInto(100, mapOf("a" to 1L)) + store.mergeInto(110, mapOf("a" to 1L)) + + val days = store.allDays() + assertNull("day 100 is older than 110-7 and must be pruned", days[100]) + assertEquals(1L, days[110]?.get("a")) + } + + @Test + fun alertStateRoundTrips() = + runTest { + val store = ResourceUsageStore(file) + assertEquals(0L, store.lastAlertAtSec()) + assertFalse(store.alertsOptOut()) + + store.markAlertPrompted(12345L) + store.setAlertsOptOut(true) + + val reloaded = ResourceUsageStore(file) + assertEquals(12345L, reloaded.lastAlertAtSec()) + assertTrue(reloaded.alertsOptOut()) + } +} + +class ResourceUsageAccountantTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accumulatesAndFlushesIntoTheRightDay() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + var day = 200L + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { day }) + + accountant.add("x", 5) + accountant.add("x", 5) + accountant.flush() + day = 201L + accountant.add("x", 7) + accountant.flush() + + val days = store.allDays() + assertEquals(10L, days[200]?.get("x")) + assertEquals(7L, days[201]?.get("x")) + } + + @Test + fun liveCountersAreVisibleBeforeFlush() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + + accountant.add("x", 42) + val days = accountant.allDaysIncludingLive() + assertEquals(42L, days[300]?.get("x")) + // and not yet on disk + assertNull(store.allDays()[300]) + } + + @Test + fun preFlushHooksRunOnFlushAndRead() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + var hookRuns = 0 + accountant.addPreFlushHook { hookRuns++ } + + accountant.flush() + accountant.allDaysIncludingLive() + assertEquals(2, hookRuns) + } +} + +class RelayConnectionTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun integratesConnectionTimeAcrossStateChanges() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(0) + val mobile = MutableStateFlow(false) + val fg = MutableStateFlow(true) + + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = mobile, + isForeground = fg, + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // 5 relays connected on wifi foreground for 10s + count.value = 5 + testScheduler.runCurrent() + now = 10_000L + + // switch to cellular background: closes the wifi segment + mobile.value = true + fg.value = false + testScheduler.runCurrent() + + // 5 relays on cellular background for 20s, then all disconnect + now = 30_000L + count.value = 0 + testScheduler.runCurrent() + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(5 * 10_000L, counters[UsageKeys.relayConnMs(mobile = false, foreground = true)]) + assertEquals(5 * 20_000L, counters[UsageKeys.relayConnMs(mobile = true, foreground = false)]) + job.cancel() + } + + @Test + fun closeOpenSegmentAccountsLongStableSessions() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + + var now = 0L + val count = MutableStateFlow(3) + val integrator = + RelayConnectionTimeIntegrator( + connectedCount = count, + isMobile = MutableStateFlow(true), + isForeground = MutableStateFlow(false), + accountant = accountant, + nowMs = { now }, + ) + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // hours pass with no state change at all (always-on background) + now = 2 * 60 * 60 * 1000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals( + "reading the ledger must account the still-open segment", + 3 * 2 * 60 * 60 * 1000L, + counters[UsageKeys.relayConnMs(mobile = true, foreground = false)], + ) + job.cancel() + } +} + +class ResourceUsageAlertsTest { + private fun day(vararg counters: Pair) = mapOf(*counters) + + @Test + fun quietUsageDoesNotAlert() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = false, received = true) to 1024L * 1024L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 60L * 60L * 1000L, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun backgroundMobileDataCrossingThresholdAlerts() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = false, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY + 1, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA, alert?.reason) + assertEquals(9L, alert?.day) + } + + @Test + fun foregroundMobileDataDoesNotTripTheBackgroundThreshold() { + val days = + mapOf( + 9L to + day( + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 10, + ), + ) + assertNull(ResourceUsageAlerts.evaluate(days, today = 10L)) + } + + @Test + fun connectionTimeCounterDoesNotLeakIntoByteThreshold() { + // relay.connms keys carry mobile+bg dims but are milliseconds, not + // bytes: they must never count toward the data threshold. + val days = + mapOf( + 9L to + day( + UsageKeys.relayConnMs(mobile = true, foreground = false) to + ResourceUsageAlerts.BG_MOBILE_BYTES_PER_DAY * 100, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME, alert?.reason) + } + + @Test + fun todayIsCheckedWhenYesterdayIsQuiet() { + val days = + mapOf( + 10L to day(UsageKeys.APP_STARTS to ResourceUsageAlerts.APP_STARTS_PER_DAY + 1), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.PROCESS_CHURN, alert?.reason) + } + + @Test + fun promptRateLimiting() { + val now = 1_000_000L + val week = ResourceUsageAlerts.MIN_DAYS_BETWEEN_PROMPTS * 24 * 60 * 60 + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week + 10, optOut = false, nowSec = now)) + assertTrue(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = now - week - 10, optOut = false, nowSec = now)) + assertFalse(ResourceUsageAlerts.shouldPrompt(lastAlertAtSec = 0, optOut = true, nowSec = now)) + } +} From 4324a8b8e48a66cea2cbc7d4477d04b6dd6c2b55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 20:57:39 +0000 Subject: [PATCH 089/206] =?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 f0f0056be44246460cc1873d584d4058c55d0680 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:16:14 +0000 Subject: [PATCH 090/206] feat(ledger): reconnect churn, process CPU, foreground time, verify metering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the resource-usage ledger with the metrics that answer "what else could be draining": - Relay (re)connection + failed-dial counters per network/visibility via the existing RelayConnectionListener hook. Every completed connect paid a TCP+TLS handshake, so high daily counts are the reconnect-churn signature — new alert threshold at >5000 connects/day. - Whole-process CPU time (Process.getElapsedCpuTime deltas sampled at ledger flush): the honest aggregate of parsing, crypto, coroutines and UI — one syscall per flush, nothing while idle. Answers whether CPU matters at all before any per-subsystem drill-down. - Foreground time (time with UI visible): what display power scales with, and the denominator that makes every other per-day number interpretable. - Signature-verification count + CPU time via a nullable meter hook on LocalCache.justVerify (wired like cache.onchainBackend), settling the "does Schnorr verify cost matter" question with data. Deliberately not tracked (documented in the plan): per-screen time (route names leak behavior patterns into a shareable report — needs its own privacy pass), per-coroutine CPU (needs a thread registry; cpu.ms first), and signing (user-action-rate, negligible). All metrics surface in the usage screen, the NIP-17 report, and UsageSummary; 4 new tests (18 total for the ledger). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 16 ++++ .../com/vitorpamplona/amethyst/AppModules.kt | 14 ++- .../amethyst/model/LocalCache.kt | 20 ++++- .../DisplayResourceUsageAlert.kt | 7 ++ .../resourceusage/ForegroundTimeIntegrator.kt | 73 ++++++++++++++++ .../resourceusage/ProcessCpuSampler.kt | 51 +++++++++++ .../resourceusage/RelayUsageListener.kt | 18 ++++ .../resourceusage/ResourceUsageAlerts.kt | 12 +++ .../ResourceUsageReportAssembler.kt | 4 + .../service/resourceusage/UsageKeys.kt | 22 +++++ .../service/resourceusage/UsageSummary.kt | 12 +++ .../loggedIn/settings/ResourceUsageScreen.kt | 8 ++ amethyst/src/main/res/values/strings.xml | 8 ++ .../resourceusage/ResourceUsageLedgerTest.kt | 87 +++++++++++++++++++ 14 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 7d460b68bf..5c8e8643a5 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -58,6 +58,22 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: - `worker.scheduledPost.runs` / `worker.calendarReminder.runs` / `worker.notificationCatchUp.runs` - `app.starts` — process starts (detects WorkManager cold-start churn) +- `relay.connects..` / `relay.connfails..` — completed + (re)connections and failed dials; each connect paid a TCP+TLS handshake, + so high daily counts are the reconnect-churn signature +- `cpu.ms` — whole-process CPU time deltas ([android.os.Process + .getElapsedCpuTime] sampled at flush): the honest aggregate of parsing, + crypto, coroutines, and UI without per-subsystem guesswork +- `app.fgms` — time with UI visible; display power is proportional to it and + it's the denominator for every per-day comparison +- `crypto.verify.count` / `crypto.verify.us` — event signature verifications + (LocalCache.justVerify hook), settling "does Schnorr verify cost matter" + with data + +Deliberately not tracked (v1): per-screen time (route names leak behavior +patterns into a report — needs its own privacy review), per-coroutine or +per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU +matters at all first), signing (user-action-rate, negligible). Flat keys keep the store schema-free: new counters need no migration. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index bb2f1700f9..c1c3fe6d71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -86,8 +86,10 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant @@ -630,7 +632,8 @@ class AppModules( // Captures statistics about relays val relayStats = RelayStats(client) - // Resource-usage ledger: relay traffic + connection-time collectors. + // Resource-usage ledger: relay traffic/reconnect + connection-time, + // foreground-time, process-CPU, and signature-verification collectors. init { client.addConnectionListener( RelayUsageListener( @@ -645,6 +648,15 @@ class AppModules( isForeground = foregroundTracker.isForeground, accountant = resourceUsage, ).start(applicationIOScope) + ForegroundTimeIntegrator( + isForeground = foregroundTracker.isForeground, + accountant = resourceUsage, + ).start(applicationIOScope) + ProcessCpuSampler(resourceUsage).register() + cache.verifyMeter = { elapsedNanos, _ -> + resourceUsage.add(UsageKeys.VERIFY_COUNT, 1) + resourceUsage.add(UsageKeys.VERIFY_US, elapsedNanos / 1_000) + } } // Logs debug messages when needed 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 58cdad1fc9..2dbba0d893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3216,10 +3216,27 @@ object LocalCache : ILocalCache, ICacheProvider { live.removedNote(newNote) } + /** + * Resource-usage ledger hook: called with (elapsedNanos, valid) for every + * signature verification so the app can account crypto CPU per day. + * Wired by AppModules like [onchainBackend]; null costs nothing. + */ + var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null + fun justVerify(event: Event): Boolean { checkNotInMainThread() - return if (!event.verify()) { + val meter = verifyMeter + if (meter == null) return justVerifyInner(event) + + val start = System.nanoTime() + val valid = justVerifyInner(event) + meter(System.nanoTime() - start, valid) + return valid + } + + private fun justVerifyInner(event: Event): Boolean = + if (!event.verify()) { try { event.checkSignature() } catch (e: Exception) { @@ -3230,7 +3247,6 @@ object LocalCache : ILocalCache, ICacheProvider { } else { true } - } fun consume( event: DraftWrapEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index f51ec4e2f0..73622fb097 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -156,4 +156,11 @@ private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String = alert.value.toInt(), alert.value.toInt(), ) + + ResourceUsageAlerts.Reason.RECONNECT_CHURN -> + pluralStringResource( + R.plurals.resource_usage_reason_reconnects, + alert.value.toInt(), + alert.value.toInt(), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt new file mode 100644 index 0000000000..125951ef8a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.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.service.resourceusage + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]). + * Screen-on time is what display power is proportional to, and it's the + * denominator that makes every other counter interpretable (MB per hour in + * app vs MB while backgrounded). Same timer-free segment pattern as + * [RelayConnectionTimeIntegrator]: segments close on transitions and on the + * accountant's pre-flush hook. + */ +class ForegroundTimeIntegrator( + private val isForeground: Flow, + private val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + private val lock = Any() + private var foreground = false + private var segmentStartMs = 0L + + fun start(scope: CoroutineScope): Job { + accountant.addPreFlushHook(::closeOpenSegment) + return scope.launch { + isForeground.collect { fg -> transitionTo(fg) } + } + } + + fun closeOpenSegment() { + synchronized(lock) { + if (!foreground) return + val now = nowMs() + account(now - segmentStartMs) + segmentStartMs = now + } + } + + private fun transitionTo(fg: Boolean) { + synchronized(lock) { + val now = nowMs() + if (foreground) account(now - segmentStartMs) + foreground = fg + segmentStartMs = now + } + } + + private fun account(elapsedMs: Long) { + if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt new file mode 100644 index 0000000000..408101afa4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ProcessCpuSampler.kt @@ -0,0 +1,51 @@ +/* + * 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.service.resourceusage + +import android.os.Process + +/** + * Samples whole-process CPU time (user+system, [Process.getElapsedCpuTime]) + * into the ledger as day deltas. This is the honest aggregate cost of + * everything that runs on the CPU — event parsing, signature verification, + * coroutines, recomposition — without per-subsystem guesswork. Sampled from + * the accountant's pre-flush hook, so it costs one syscall per flush and + * nothing while idle. Per-process monotonic: a fresh process simply starts a + * fresh baseline. + */ +class ProcessCpuSampler( + private val accountant: ResourceUsageAccountant, + private val cpuMs: () -> Long = { Process.getElapsedCpuTime() }, +) { + private var lastSampleMs = cpuMs() + + fun register() { + accountant.addPreFlushHook(::sample) + } + + @Synchronized + fun sample() { + val now = cpuMs() + val delta = now - lastSampleMs + lastSampleMs = now + if (delta > 0) accountant.add(UsageKeys.CPU_MS, delta) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index 190649036f..a8ba773138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -55,4 +55,22 @@ class RelayUsageListener( ) { accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong()) } + + // Every completed (re)connection paid a TCP+TLS handshake; high daily + // counts are the signature of reconnect churn (flaky network, aggressive + // relay idle timeouts, Tor bootstrap loops). + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1) + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt index 4180ef78cc..b72cbdf173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAlerts.kt @@ -37,6 +37,7 @@ object ResourceUsageAlerts { BACKGROUND_MOBILE_CONNECTION_TIME, WAKELOCK_TIME, PROCESS_CHURN, + RECONNECT_CHURN, } data class Alert( @@ -57,6 +58,14 @@ object ResourceUsageAlerts { /** > 75 process starts in one day (WorkManager/restart churn). */ const val APP_STARTS_PER_DAY = 75L + /** + * > 5000 completed relay (re)connections in one day. A healthy day is a + * few hundred to ~2000 even with a large relay set; sustained thousands + * means something is cycling (a stuck relay tier, a flapping network, a + * Tor bootstrap loop) and every cycle pays a TLS handshake. + */ + const val RELAY_CONNECTS_PER_DAY = 5_000L + const val MIN_DAYS_BETWEEN_PROMPTS = 7L /** @@ -84,6 +93,9 @@ object ResourceUsageAlerts { if (summary.appStarts > APP_STARTS_PER_DAY) { return Alert(Reason.PROCESS_CHURN, day, summary.appStarts) } + if (summary.relayConnects > RELAY_CONNECTS_PER_DAY) { + return Alert(Reason.RECONNECT_CHURN, day, summary.relayConnects) + } } return null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 47c8493b8b..ae96b6f45b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -81,6 +81,10 @@ class ResourceUsageReportAssembler { append("| Relay connection time | ${formatConnHours(s.relayConnMs)} |\n") append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") + append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") + append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") + append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") + append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") append("| Background worker runs | ${s.workerRuns} |\n") append("| App process starts | ${s.appStarts} |\n") val subsystems = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index ef04e0ea55..93918f5d4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -72,6 +72,18 @@ object UsageKeys { foreground: Boolean, ): String = "relay.connms.${dim(mobile, foreground)}" + /** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */ + fun relayConnects( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connects.${dim(mobile, foreground)}" + + /** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */ + fun relayConnectFails( + mobile: Boolean, + foreground: Boolean, + ): String = "relay.connfails.${dim(mobile, foreground)}" + /** `worker.scheduledPost.runs` */ fun workerRuns(worker: String): String = "worker.$worker.runs" @@ -79,6 +91,16 @@ object UsageKeys { const val WAKELOCK_NOTIF_COUNT = "wakelock.notif.count" const val APP_STARTS = "app.starts" + /** Whole-process CPU time (user+system) — the honest aggregate of parsing, crypto, coroutines, and UI. */ + const val CPU_MS = "cpu.ms" + + /** Time with at least one activity STARTED — the denominator that makes the other counters interpretable. */ + const val APP_FG_MS = "app.fgms" + + /** Event signature verifications (LocalCache.justVerify). */ + const val VERIFY_COUNT = "crypto.verify.count" + const val VERIFY_US = "crypto.verify.us" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 1e6d7e6b38..94faefeec3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -40,6 +40,12 @@ data class UsageSummary( val wakelockCount: Long, val workerRuns: Long, val appStarts: Long, + val relayConnects: Long, + val relayConnectFails: Long, + val cpuMs: Long, + val foregroundMs: Long, + val verifyCount: Long, + val verifyUs: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -75,6 +81,12 @@ data class UsageSummary( wakelockCount = counters[UsageKeys.WAKELOCK_NOTIF_COUNT] ?: 0L, workerRuns = counters.sumMatching("worker", "runs"), appStarts = counters[UsageKeys.APP_STARTS] ?: 0L, + relayConnects = counters.sumMatching("connects"), + relayConnectFails = counters.sumMatching("connfails"), + cpuMs = counters[UsageKeys.CPU_MS] ?: 0L, + foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L, + verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L, + verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L, bytesPerSubsystem = subsystems, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 06324565ab..b29bb1f5ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -129,6 +129,14 @@ private fun UsageSummarySection( SettingsDivider() MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) SettingsDivider() + MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") + SettingsDivider() + MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_fg_time, formatDurationMs(s.foregroundMs)) + SettingsDivider() MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) SettingsDivider() MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0239d5a241..a6f2778da6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3192,6 +3192,10 @@ \u2026 backgrounded on cellular Notification processing (CPU awake) Background jobs run + Relay reconnections (failed) + Signatures verified + Processor time used + Time in app App process starts Data by feature (7 days) Relay sync @@ -3216,6 +3220,10 @@ the app process restarted %1$d time in one day the app process restarted %1$d times in one day + + the app reconnected to relays %1$d time in one day + the app reconnected to relays %1$d times in one day + Review & send Not now Don\u2019t ask again diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 7a9c0cf9c1..677930b284 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -213,6 +213,78 @@ class RelayConnectionTimeIntegratorTest { } } +class ProcessCpuSamplerTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsCpuDeltasAcrossSamples() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var cpu = 1_000L + val sampler = ProcessCpuSampler(accountant) { cpu } + + cpu = 1_500L + sampler.sample() + cpu = 1_800L + sampler.sample() + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(800L, counters[UsageKeys.CPU_MS]) + } +} + +class ForegroundTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsOnlyForegroundTime() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 0L + val fg = MutableStateFlow(false) + val integrator = ForegroundTimeIntegrator(fg, accountant) { now } + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + // 60s in background — must not count + now = 60_000L + fg.value = true + testScheduler.runCurrent() + + // 30s in foreground — counts + now = 90_000L + fg.value = false + testScheduler.runCurrent() + + // 60s more in background, then read: still 30s + now = 150_000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(30_000L, counters[UsageKeys.APP_FG_MS]) + job.cancel() + } + + @Test + fun openForegroundSegmentIsAccountedOnRead() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 0L + val fg = MutableStateFlow(true) + val integrator = ForegroundTimeIntegrator(fg, accountant) { now } + val job = integrator.start(backgroundScope) + testScheduler.runCurrent() + + now = 45_000L + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(45_000L, counters[UsageKeys.APP_FG_MS]) + job.cancel() + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) @@ -283,6 +355,21 @@ class ResourceUsageAlertsTest { assertEquals(ResourceUsageAlerts.Reason.PROCESS_CHURN, alert?.reason) } + @Test + fun reconnectChurnAlerts() { + val days = + mapOf( + 9L to + day( + UsageKeys.relayConnects(mobile = true, foreground = false) to 3_000L, + UsageKeys.relayConnects(mobile = false, foreground = true) to + ResourceUsageAlerts.RELAY_CONNECTS_PER_DAY - 2_000L, + ), + ) + val alert = ResourceUsageAlerts.evaluate(days, today = 10L) + assertEquals(ResourceUsageAlerts.Reason.RECONNECT_CHURN, alert?.reason) + } + @Test fun promptRateLimiting() { val now = 1_000_000L From 716a7c82cb428a304f3cf9372e21aeeb3fad033b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 21:24:46 +0000 Subject: [PATCH 091/206] feat(ledger): live memory card on the usage screen; retire the debug-only chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the debug-build memory chip (home top bar) into the resource-usage screen as a "Memory right now" card visible to everyone: app heap, native heap, Coil RAM/disk cache fill, LocalCache note/profile/addressable/ chatroom counts, and the device memory class — refreshed every 2s only while the screen is composed (same off-main collection the chip used, DiskLruCache.size() contends with journal I/O). The same snapshot is appended to the NIP-17 usage report (both from the screen's send button and the high-consumption dialog), so a report now also shows whether the device was under memory pressure at send time. MemoryUsageChip.kt is deleted and the chip call removed from UserDrawerSearchTopBar — the debug feature is retired from the home UI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../DisplayResourceUsageAlert.kt | 14 ++- .../ResourceUsageReportAssembler.kt | 14 +++ .../ui/navigation/topbars/MemoryUsageChip.kt | 116 ------------------ .../topbars/UserDrawerSearchTopBar.kt | 1 - .../loggedIn/settings/ResourceUsageScreen.kt | 45 ++++++- amethyst/src/main/res/values/strings.xml | 10 ++ 6 files changed, 77 insertions(+), 123 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index 73622fb097..ddac466f85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -29,9 +29,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage @@ -60,6 +62,7 @@ fun DisplayResourceUsageAlert( accountViewModel: AccountViewModel, nav: INav, ) { + val context = LocalContext.current val alert = remember { mutableStateOf(null) } LaunchedEffect(accountViewModel) { @@ -109,10 +112,13 @@ fun DisplayResourceUsageAlert( Button(onClick = { nav.nav { val report = - ResourceUsageReportAssembler().buildReport( - Amethyst.instance.resourceUsage.allDaysIncludingLive(), - Amethyst.instance.resourceUsage.today(), - ) + withContext(Dispatchers.IO) { + ResourceUsageReportAssembler().buildReport( + Amethyst.instance.resourceUsage.allDaysIncludingLive(), + Amethyst.instance.resourceUsage.today(), + collectMemorySnapshot(context), + ) + } routeToMessage( user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), draftMessage = report, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index ae96b6f45b..8e4c9254b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.resourceusage import android.os.Build import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.MemorySnapshot import java.util.Locale /** @@ -35,6 +36,7 @@ class ResourceUsageReportAssembler { fun buildReport( days: Map>, today: Long, + memory: MemorySnapshot? = null, ): String { val sb = StringBuilder() sb.append("Resource Usage Report: ") @@ -59,6 +61,18 @@ class ResourceUsageReportAssembler { sb.append("\n**Last 7 days**\n\n") sb.append(summaryTable(UsageSummary.fromDays(weekCounters))) + if (memory != null) { + sb.append("\n**Memory right now**\n\n") + sb.append("| Metric | Value |\n") + sb.append("| --- | --- |\n") + sb.append("| Device class | ${memory.memoryClassMb} MB |\n") + sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n") + sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n") + sb.append("| Image cache (RAM) | ${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB |\n") + sb.append("| Image cache (disk) | ${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB |\n") + sb.append("| Cached notes/users/addressables/chatrooms | ${memory.noteCount}/${memory.userCount}/${memory.addressableCount}/${memory.chatroomCount} |\n") + } + sb.append("\nTechnical details (per epoch-day):\n") sb.append("```\n") days.toSortedMap().forEach { (day, counters) -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt deleted file mode 100644 index 85a205c84e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/MemoryUsageChip.kt +++ /dev/null @@ -1,116 +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.amethyst.ui.navigation.topbars - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -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.unit.dp -import com.vitorpamplona.amethyst.MemorySnapshot -import com.vitorpamplona.amethyst.collectMemorySnapshot -import com.vitorpamplona.amethyst.isDebug -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext - -@Composable -fun MemoryUsageChip() { - if (!isDebug) return - - val context = LocalContext.current - var showDetail by remember { mutableStateOf(false) } - - val snapshot by produceState(null) { - while (true) { - // collectMemorySnapshot reads coil3.disk.DiskLruCache.size(), which is @Synchronized and - // contends with the disk cache's own journal I/O. On cold start that lock is held by a - // background worker for seconds (initial journal read + the burst of image writes), so - // running this on the produceState default (main) dispatcher froze the UI thread — - // the "Loading account" frame couldn't repaint until size() returned. Collect off-main. - value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) } - delay(2_000) - } - } - - val s = snapshot ?: return - - val chipColor = - when { - s.heapFraction > 0.80f -> Color(0xFFE53935) // red - s.heapFraction > 0.60f -> Color(0xFFFFA000) // amber - else -> Color(0xFF43A047) // green - } - - Text( - text = "${s.heapUsedMb}/${s.heapMaxMb}MB", - color = chipColor, - style = MaterialTheme.typography.labelSmall, - modifier = - Modifier - .padding(horizontal = 4.dp) - .clickable { showDetail = true }, - ) - - if (showDetail) { - MemoryDetailDialog(snapshot = s, onDismiss = { showDetail = false }) - } -} - -@Composable -private fun MemoryDetailDialog( - snapshot: MemorySnapshot, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Memory Usage") }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Device class: ${snapshot.memoryClassMb} MB") - Text("JVM heap: ${snapshot.heapUsedMb} / ${snapshot.heapMaxMb} MB") - Text("Native heap: ${snapshot.nativeHeapUsedMb} MB") - Text("Coil memory: ${snapshot.imageCacheUsedMb} / ${snapshot.imageCacheMaxMb} MB") - Text("Coil disk: ${snapshot.imageDiskUsedMb} / ${snapshot.imageDiskMaxMb} MB") - Text("Notes: ${snapshot.noteCount}") - Text("Users: ${snapshot.userCount}") - Text("Addressables: ${snapshot.addressableCount}") - Text("Chatrooms: ${snapshot.chatroomCount}") - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { Text("OK") } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 5ab67c459f..efdcfff438 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -75,7 +75,6 @@ fun UserDrawerSearchTopBar( } }, actions = { - MemoryUsageChip() IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index b29bb1f5ec..f7d10737aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -36,14 +36,18 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.MemorySnapshot import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler @@ -57,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext /** @@ -79,6 +84,16 @@ fun ResourceUsageScreen( } } + // Live memory snapshot, refreshed only while this screen is composed. + // Collected off-main: DiskLruCache.size() contends with journal I/O. + val context = LocalContext.current + val memory by produceState(null) { + while (true) { + value = withContext(Dispatchers.IO) { collectMemorySnapshot(context) } + delay(2_000) + } + } + Scaffold( topBar = { TopBarWithBackButton(stringRes(id = R.string.resource_usage_title), nav) }, ) { padding -> @@ -105,7 +120,8 @@ fun ResourceUsageScreen( UsageSummarySection(R.string.resource_usage_today, todaySummary) UsageSummarySection(R.string.resource_usage_week, weekSummary) SubsystemSection(weekSummary) - SendReportSection(accountViewModel, nav, loaded, today) + MemorySection(memory) + SendReportSection(accountViewModel, nav, loaded, today, memory) } } } @@ -191,12 +207,37 @@ private fun MetricRow( } } +@Composable +private fun MemorySection(memory: MemorySnapshot?) { + if (memory == null) return + SettingsSection(R.string.resource_usage_memory_section) { + MetricRow(R.string.resource_usage_memory_heap, "${memory.heapUsedMb} / ${memory.heapMaxMb} MB") + SettingsDivider() + MetricRow(R.string.resource_usage_memory_native, "${memory.nativeHeapUsedMb} MB") + SettingsDivider() + MetricRow(R.string.resource_usage_memory_image_cache, "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB") + SettingsDivider() + MetricRow(R.string.resource_usage_memory_image_disk, "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB") + SettingsDivider() + MetricRow(R.string.resource_usage_memory_notes, memory.noteCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_memory_users, memory.userCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_memory_addressables, memory.addressableCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_memory_chatrooms, memory.chatroomCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_memory_device_class, "${memory.memoryClassMb} MB") + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, nav: INav, days: Map>, today: Long, + memory: MemorySnapshot?, ) { SettingsSection(R.string.resource_usage_send_section) { Column( @@ -210,7 +251,7 @@ private fun SendReportSection( ) Button( onClick = { - val report = ResourceUsageReportAssembler().buildReport(days, today) + val report = ResourceUsageReportAssembler().buildReport(days, today, memory) nav.nav { routeToMessage( user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a6f2778da6..cd3650cec8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3208,6 +3208,16 @@ Push registration Other No usage recorded yet. + Memory right now + App memory (heap) + Native memory + Image cache (RAM) + Image cache (disk) + Notes in memory + Profiles in memory + Addressable events in memory + Chatroom lists in memory + Device memory class Share with the developers If Amethyst seems to drain battery or data, you can send this report to the developers in an encrypted DM. It contains only the numbers on this screen and the technical counters behind them \u2014 no posts, contacts, or browsing details. Nothing is sent until you tap Send in the message screen. Send report via DM From fe780a4220b5d604af6c895a46845e16a9efd41a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:15:04 +0000 Subject: [PATCH 092/206] 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 64d6c038a72756d17ba955c42b95a9c5f3ec0d22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 22:15:04 +0000 Subject: [PATCH 093/206] =?UTF-8?q?feat(ledger):=20redesign=20the=20usage?= =?UTF-8?q?=20screen=20=E2=80=94=20stat=20tiles,=20trend=20chart,=20propor?= =?UTF-8?q?tion=20bars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version was a wall of identical label-value rows: correct but unreadable at a glance. The redesign gives the screen visual hierarchy and proportion: - Headline 2x2 stat tiles for today (cellular, wi-fi, relay time, time in app) — the numbers that matter get the visual weight. - A 7-day data-per-day trend chart (Vico, same line/area idioms and weekday axis as the notification summary chart) with a cellular/wi-fi legend. Color pairs validated for CVD separation and lightness on both surfaces: BitcoinOrange+RoyalBlue on light, #C77414+RoyalBlue on dark (a selected dark variant, not an automatic flip). - Data-by-feature as ranked proportion bars, so "video dwarfs relays" is visible without reading numbers. - Live memory as meters: the heap bar keeps the retired debug chip's green/amber/red thresholds; image caches as fill bars. - Activity counters stay as compact rows under one section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../loggedIn/settings/ResourceUsageScreen.kt | 200 ++++++++++++++---- .../loggedIn/settings/UsageTrendChart.kt | 174 +++++++++++++++ amethyst/src/main/res/values/strings.xml | 8 + 3 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index f7d10737aa..8d685c1a37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -21,14 +21,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings import androidx.annotation.StringRes +import androidx.compose.foundation.background 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -41,6 +48,8 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -70,6 +79,9 @@ import kotlinx.coroutines.withContext * user-initiated path to DM the numbers to the developers (same NIP-17 flow * as crash reports). Everything on this screen stays on-device until the * user sends that DM. + * + * Layout: headline stat tiles (today) → 7-day trend chart → per-feature + * proportion bars → activity counters → live memory meters → send card. */ @Composable fun ResourceUsageScreen( @@ -117,9 +129,14 @@ fun ResourceUsageScreen( val todaySummary = UsageSummary.from(loaded[today].orEmpty()) val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) - UsageSummarySection(R.string.resource_usage_today, todaySummary) - UsageSummarySection(R.string.resource_usage_week, weekSummary) + TodayTiles(todaySummary) + if (weekSummary.totalBytes > 0) { + SettingsSection(R.string.resource_usage_trend_section) { + UsageTrendChart(loaded, today) + } + } SubsystemSection(weekSummary) + ActivitySection(weekSummary) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -127,46 +144,64 @@ fun ResourceUsageScreen( } } +/** Headline numbers for today as a 2x2 tile grid. */ @Composable -private fun UsageSummarySection( - @StringRes title: Int, - s: UsageSummary, -) { - SettingsSection(title) { - MetricRow(R.string.resource_usage_cellular_bg, formatBytes(s.mobileBytesBg)) - SettingsDivider() - MetricRow(R.string.resource_usage_cellular_fg, formatBytes(s.mobileBytesFg)) - SettingsDivider() - MetricRow(R.string.resource_usage_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg)) - SettingsDivider() - MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) - SettingsDivider() - MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") - SettingsDivider() - MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) - SettingsDivider() - MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_fg_time, formatDurationMs(s.foregroundMs)) - SettingsDivider() - MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) - SettingsDivider() - MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) +private fun TodayTiles(s: UsageSummary) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile(R.string.resource_usage_tile_cellular, formatBytes(s.mobileBytes), Modifier.weight(1f)) + StatTile(R.string.resource_usage_tile_wifi, formatBytes(s.wifiBytesBg + s.wifiBytesFg), Modifier.weight(1f)) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + StatTile(R.string.resource_usage_tile_relay, formatConnHours(s.relayConnMs), Modifier.weight(1f)) + StatTile(R.string.resource_usage_tile_in_app, formatDurationMs(s.foregroundMs), Modifier.weight(1f)) + } } } +@Composable +private fun StatTile( + @StringRes label: Int, + value: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +/** Ranked per-feature traffic with proportion bars (7 days). */ @Composable private fun SubsystemSection(week: UsageSummary) { if (week.bytesPerSubsystem.isEmpty()) return + val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } + val max = rows.first().value.coerceAtLeast(1L) SettingsSection(R.string.resource_usage_by_subsystem) { - val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } - rows.forEachIndexed { index, (subsystem, bytes) -> - if (index > 0) SettingsDivider() - MetricRow(subsystemLabel(subsystem), formatBytes(bytes)) + rows.forEach { (subsystem, bytes) -> + BarRow( + label = subsystemLabel(subsystem), + value = formatBytes(bytes), + fraction = bytes.toFloat() / max.toFloat(), + ) } } } @@ -185,6 +220,72 @@ private fun subsystemLabel(subsystem: String): Int = else -> R.string.resource_usage_subsystem_other } +/** Label + value + a thin rounded proportion bar underneath. */ +@Composable +private fun BarRow( + @StringRes label: Int, + value: String, + fraction: Float, + color: Color = MaterialTheme.colorScheme.primary, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringRes(label), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Box( + modifier = + Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(fraction.coerceIn(0f, 1f)) + .fillMaxHeight() + .clip(RoundedCornerShape(4.dp)) + .background(color), + ) + } + } +} + +/** Background/CPU activity counters for the last 7 days. */ +@Composable +private fun ActivitySection(s: UsageSummary) { + SettingsSection(R.string.resource_usage_activity_section) { + MetricRow(R.string.resource_usage_relay_time, formatConnHours(s.relayConnMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_relay_time_bg_mobile, formatConnHours(s.relayConnMsMobileBg)) + SettingsDivider() + MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") + SettingsDivider() + MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_wakelock, formatDurationMs(s.wakelockMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + } +} + @Composable private fun MetricRow( @StringRes label: Int, @@ -207,18 +308,39 @@ private fun MetricRow( } } +/** + * Live memory meters. The heap bar keeps the retired debug chip's status + * semantics: green below 60% of the max heap, amber to 80%, red above. + */ @Composable private fun MemorySection(memory: MemorySnapshot?) { if (memory == null) return + val heapColor = + when { + memory.heapFraction > 0.80f -> Color(0xFFE53935) + memory.heapFraction > 0.60f -> Color(0xFFFFA000) + else -> Color(0xFF43A047) + } SettingsSection(R.string.resource_usage_memory_section) { - MetricRow(R.string.resource_usage_memory_heap, "${memory.heapUsedMb} / ${memory.heapMaxMb} MB") + BarRow( + label = R.string.resource_usage_memory_heap, + value = "${memory.heapUsedMb} / ${memory.heapMaxMb} MB", + fraction = memory.heapFraction, + color = heapColor, + ) + BarRow( + label = R.string.resource_usage_memory_image_cache, + value = "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB", + fraction = memory.imageCacheUsedMb.toFloat() / memory.imageCacheMaxMb.coerceAtLeast(1L).toFloat(), + ) + BarRow( + label = R.string.resource_usage_memory_image_disk, + value = "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB", + fraction = memory.imageDiskUsedMb.toFloat() / memory.imageDiskMaxMb.coerceAtLeast(1L).toFloat(), + ) SettingsDivider() MetricRow(R.string.resource_usage_memory_native, "${memory.nativeHeapUsedMb} MB") SettingsDivider() - MetricRow(R.string.resource_usage_memory_image_cache, "${memory.imageCacheUsedMb} / ${memory.imageCacheMaxMb} MB") - SettingsDivider() - MetricRow(R.string.resource_usage_memory_image_disk, "${memory.imageDiskUsedMb} / ${memory.imageDiskMaxMb} MB") - SettingsDivider() MetricRow(R.string.resource_usage_memory_notes, memory.noteCount.toString()) SettingsDivider() MetricRow(R.string.resource_usage_memory_users, memory.userCount.toString()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.kt new file mode 100644 index 0000000000..fed0882e3b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UsageTrendChart.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.amethyst.ui.screen.loggedIn.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.unit.dp +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.CartesianMeasuringContext +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModel +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter +import com.patrykandpatrick.vico.compose.cartesian.data.LineCartesianLayerModel +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.LastWeekLabelFormatter +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.chart.makeLine +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.RoyalBlue +import kotlin.math.roundToInt + +/** + * Cellular-on-dark-surfaces variant of BitcoinOrange. The light/dark pairs + * (#F7931A + #4169E1 on light; #C77414 + #4169E1 on dark) were validated for + * CVD separation, lightness band, and chroma with the dataviz palette checks; + * the light orange's low surface contrast is relieved by the text legend. + */ +private val CellularOnDark = Color(0xFFC77414) + +/** + * 7-day data-per-day trend: one line per network class, MB per day, today at + * the right edge. Reuses the notification chart's Vico idioms ([makeLine], + * [LastWeekLabelFormatter], x = -6..0 with today at 0). + */ +@Composable +fun UsageTrendChart( + days: Map>, + today: Long, +) { + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val cellularColor = if (isDark) CellularOnDark else BitcoinOrange + val wifiColor = RoyalBlue + + val model = + remember(days, today) { + val xs = (-6..0).toList() + val cellular = + xs.map { offset -> + UsageSummary.from(days[today + offset].orEmpty()).mobileBytes.toMb() + } + val wifi = + xs.map { offset -> + val s = UsageSummary.from(days[today + offset].orEmpty()) + (s.wifiBytesBg + s.wifiBytesFg).toMb() + } + CartesianChartModel( + LineCartesianLayerModel.build { + series(xs, cellular) + series(xs, wifi) + }, + ) + } + + val chart = + rememberCartesianChart( + layers = + arrayOf( + LineCartesianLayer( + LineCartesianLayer.LineProvider.series( + makeLine(cellularColor), + makeLine(wifiColor), + ), + ), + ), + startAxis = + VerticalAxis.rememberStart( + valueFormatter = MegabyteAxisFormatter, + itemPlacer = VerticalAxis.ItemPlacer.count({ 5 }), + ), + bottomAxis = + HorizontalAxis.rememberBottom( + valueFormatter = LastWeekLabelFormatter(), + ), + ) + + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + CartesianChartHost( + chart = chart, + model = model, + modifier = Modifier.fillMaxWidth().height(170.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LegendEntry(cellularColor, R.string.resource_usage_legend_cellular) + LegendEntry(wifiColor, R.string.resource_usage_legend_wifi) + } + } +} + +@Composable +private fun LegendEntry( + color: Color, + @StringRes label: Int, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(10.dp).background(color, CircleShape)) + Text( + text = stringRes(label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun Long.toMb(): Float = this / 1_048_576f + +private val MegabyteAxisFormatter = + object : CartesianValueFormatter { + override fun format( + context: CartesianMeasuringContext, + value: Double, + verticalAxisPosition: Axis.Position.Vertical?, + ): CharSequence = + if (value >= 1024) { + "%.1fG".format(value / 1024.0) + } else { + "${value.roundToInt()}M" + } + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cd3650cec8..240c09b37c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3185,6 +3185,14 @@ battery data usage consumption power network diagnostics ledger Today Last 7 days + Cellular today + Wi-Fi today + Relay time today + In app today + Data per day + Cellular (MB) + Wi-Fi (MB) + Activity (7 days) Cellular data (background) Cellular data (in app) Wi-Fi data From f8416f4940f65694581093aecc1477e5ce082b29 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:07 -0400 Subject: [PATCH 094/206] feat(relay-auth): per-account NIP-42 override store + state on Account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-relay ALLOW/DENY AUTH overrides (and the policy ledger that reads them) lived in one process-wide DataStore keyed only by relay URL, so a DENY set for one account silently applied to every logged-in account. Move them to a per-account file under accounts// and warm-cache the overrides in memory on the Account (RelayAuthPermissionCache) so an AUTH challenge is answered without a disk read. DataStoreRelayAuthPermissionStore now shares one DataStore per file path — DataStore v1 forbids two live instances on one file, and loadAccount can build the store more than once per account. Introduces the per-account state holders wired up by the following commits: Account.relayAuthPermissions, relayAuthLedger and relayNotifications. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../vitorpamplona/amethyst/model/Account.kt | 43 +++++++ .../model/accountsCache/AccountCacheState.kt | 6 + .../DataStoreRelayAuthPermissionStore.kt | 19 +++- .../model/InMemoryRelayAuthPermissionStore.kt | 68 ++++++++++++ .../model/RelayAuthPermissionCache.kt | 105 ++++++++++++++++++ 5 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.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 798aef0e4a..c374cc6d95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -51,6 +51,8 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory @@ -127,6 +129,10 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.uploads.FileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor @@ -170,6 +176,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.normalizeRelayUrlOrNull import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -339,6 +346,7 @@ class Account( val marmotMessageStore: com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore? = null, val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, + relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), ) : IAccount { private var userProfileCache: User? = null @@ -353,6 +361,41 @@ class Account( val userMetadata = UserMetadataState(signer, cache, scope, settings) + // Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is + // answered without a disk read. Backed by a per-account file (see AccountCacheState). + val relayAuthPermissions = RelayAuthPermissionCache(relayAuthPermissionStore, scope) + + // Per-account NIP-42 policy evaluator (blocked → per-relay override → global policy → prompt), + // reading THIS account's own toggles, relay lists and follow graph. Cached here so every AUTH + // path (foreground screen + background notification consumer) shares one instance, and so an + // AUTH challenge is decided per account instead of folding every logged-in account together. + val relayAuthLedger = + RelayAuthPermissionLedger( + store = relayAuthPermissions, + globalPolicy = { settings.defaultRelayAuthPolicy.value }, + customToggles = { + RelayAuthCustomToggles( + myRelaysAndVenues = settings.relayAuthTrustMyRelaysAndVenues.value, + readFollows = settings.relayAuthTrustReadFollows.value, + messageFollows = settings.relayAuthTrustMessageFollows.value, + messageStrangers = settings.relayAuthTrustMessageStrangers.value, + ) + }, + isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false }, + isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false }, + isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors }, + isTrustedVenue = { venueId -> + venueId in publicChatList.flowSet.value || + venueId in communityList.flowSet.value || + Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true + }, + ) + + // Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming + // NOTIFY to the account whose AUTH the relay rejected and drops it here, so a prompt for one + // account never surfaces under another (the old cache was a process-wide singleton). + val relayNotifications = NotifyRequestsCache() + override val nip47SignerState = NwcSignerState(signer, nwcFilterAssembler, cache, scope, settings) val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..7a07f95469 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore import com.vitorpamplona.amethyst.model.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.service.location.LocationState +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -224,6 +225,10 @@ class AccountCacheState( null } + // Per-account NIP-42 ALLOW/DENY overrides live in this account's own dir, so a DENY for one + // account never leaks into another (the store used to be a single app-wide file). + val relayAuthPermissionStore = DataStoreRelayAuthPermissionStore(accountDir) + return Account( settings = accountSettings, signer = signerWithClientTag, @@ -247,6 +252,7 @@ class AccountCacheState( marmotMessageStore = marmotMessageStore, marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, + relayAuthPermissionStore = relayAuthPermissionStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt index abdb5c5e86..fe2e79fffd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/DataStoreRelayAuthPermissionStore.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap /** * Single-file DataStore-backed [RelayAuthPermissionStore]. All per-relay ALLOW/DENY overrides @@ -45,11 +46,10 @@ class DataStoreRelayAuthPermissionStore( ) : RelayAuthPermissionStore { constructor(context: Context) : this(context.applicationContext.filesDir) - private val store: DataStore by lazy { - PreferenceDataStoreFactory.create( - produceFile = { File(filesDir, "datastore/relay_auth.preferences_pb") }, - ) - } + // DataStore v1 throws if two instances are ever active on the same file. loadAccount can build + // this store more than once for the same account (re-login, cache races), so the underlying + // DataStore is shared per absolute file path across the process instead of created per instance. + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/relay_auth.preferences_pb")) override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { val raw = store.data.first()[decisionKey(relayUrl)] ?: return null @@ -198,6 +198,15 @@ class DataStoreRelayAuthPermissionStore( private fun lastUsedKey(relayUrl: String) = stringPreferencesKey("$LAST_USED_PREFIX${hash(relayUrl)}") companion object { + // One DataStore per file path, process-wide. computeIfAbsent runs the factory at most once + // per path, so concurrent constructions for the same account share a single active DataStore. + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + private const val DECISION_PREFIX = "allow:" private const val URL_PREFIX = "url:" private const val RATIONALE_PREFIX = "rat:" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.kt new file mode 100644 index 0000000000..eb4c514f15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/InMemoryRelayAuthPermissionStore.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.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore + +/** + * Volatile, non-persistent [RelayAuthPermissionStore]. Used as the default for [com.vitorpamplona.amethyst.model.Account] + * instances built without a disk-backed store (Compose previews, unit tests, the mock account view + * models) so nothing on the auth path has to null-check the store. + */ +class InMemoryRelayAuthPermissionStore : RelayAuthPermissionStore { + private val decisions = mutableMapOf() + private val rationale = mutableMapOf>>() + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = decisions[relayUrl] + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + decisions[relayUrl] = decision + } + + override suspend fun clearDecision(relayUrl: String) { + decisions.remove(relayUrl) + } + + override suspend fun allDecisions(): Map = decisions.toMap() + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) { + val forRelay = rationale.getOrPut(relayUrl) { mutableMapOf() } + for ((kind, pubkeys) in additions) { + forRelay.getOrPut(kind) { mutableSetOf() }.addAll(pubkeys) + } + } + + override suspend fun loadRationale(relayUrl: String): Map> = rationale[relayUrl]?.mapValues { it.value.toSet() } ?: emptyMap() + + override suspend fun allRationales(): Map>> = rationale.mapValues { entry -> entry.value.mapValues { it.value.toSet() } } + + override suspend fun clearRationale(relayUrl: String) { + rationale.remove(relayUrl) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.kt new file mode 100644 index 0000000000..4f2ab00362 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionCache.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.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * In-memory, warm-cached view over one account's [RelayAuthPermissionStore] (a per-account file — + * see [DataStoreRelayAuthPermissionStore] built from `accounts//`). Held on the + * [com.vitorpamplona.amethyst.model.Account] like the other state caches. + * + * The ALLOW/DENY overrides are the only thing read on the hot NIP-42 decision path + * ([RelayAuthPermissionLedger.decide]). They are snapshotted into memory once, right after the + * account loads, and served from there — so an incoming AUTH challenge is answered without a disk + * read (a slow disk hit here would stall login-time relay auth). Writes update memory *and* disk. + * + * Rationale + last-used are read only by the settings screen, off the hot path, so they pass + * straight through to disk. Implements [RelayAuthPermissionStore] so it drops into every existing + * call site (the ledger and the settings screen) unchanged. + */ +class RelayAuthPermissionCache( + private val disk: RelayAuthPermissionStore, + scope: CoroutineScope, +) : RelayAuthPermissionStore { + private val loaded = CompletableDeferred() + private val _overrides = MutableStateFlow>(emptyMap()) + + /** Per-relay overrides for this account, observable so the settings screen refreshes on change. */ + val overrides: StateFlow> = _overrides.asStateFlow() + + init { + scope.launch { + _overrides.value = disk.allDecisions() + loaded.complete(Unit) + } + } + + /** Non-suspending override lookup — returns null until the initial warm load finishes. */ + fun decisionOrNull(relayUrl: String): RelayAuthDecision? = _overrides.value[relayUrl] + + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? { + loaded.await() + return _overrides.value[relayUrl] + } + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) { + disk.storeDecision(relayUrl, decision) + _overrides.update { it + (relayUrl to decision) } + } + + override suspend fun clearDecision(relayUrl: String) { + disk.clearDecision(relayUrl) + _overrides.update { it - relayUrl } + } + + override suspend fun allDecisions(): Map { + loaded.await() + return _overrides.value + } + + // --- rationale + last-used: settings-screen only, never on the auth decision path --- + + override suspend fun recordUse( + relayUrl: String, + additions: Map>, + ) = disk.recordUse(relayUrl, additions) + + override suspend fun loadRationale(relayUrl: String): Map> = disk.loadRationale(relayUrl) + + override suspend fun allRationales(): Map>> = disk.allRationales() + + override suspend fun clearRationale(relayUrl: String) = disk.clearRationale(relayUrl) + + override suspend fun allLastUsed(): Map = disk.allLastUsed() +} From 0ec63958b87f12a55077de1a53d7800c0718cd75 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:22 -0400 Subject: [PATCH 095/206] fix(relay-auth): only AUTH a relay with accounts that actually use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One shared NostrClient serves every logged-in account, so a relay's NIP-42 AUTH challenge is not tied to any one of them. AuthCoordinator used to fold every account's verdict into one decision and then sign with EVERY account (or a random throwaway key), so a paid relay like inbox.nostr.wine billed and de-anonymized accounts that never used it — including via a merged read filter that merely named them, and via account switches / the background notification consumer. Decide and sign PER ACCOUNT now: an account signs only if the relay is in its own relay list, or it is publishing its own event there (RelayAuthFirstParty), AND its own ledger verdict (Account.relayAuthLedger) allows it. A subscription merely naming the account is NOT first-party — that is the merged-filter false positive. The random-ephemeral-key fallback is removed. Own-inbox reads still qualify: the relay serving them is by definition in the account's own list. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../compose/AccountDataSourceSubscription.kt | 47 +------ .../authCommand/model/AuthCoordinator.kt | 121 +++++++++++------- .../model/ListWithUniqueSetCache.kt | 6 + .../authCommand/model/RelayAuthFirstParty.kt | 56 ++++++++ .../relayauth/RelayAuthSettingsScreen.kt | 8 +- .../model/RelayAuthFirstPartyTest.kt | 74 +++++++++++ 6 files changed, 216 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt index c8a18ce7ef..194c106cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/AccountDataSourceSubscription.kt @@ -24,16 +24,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.remember import com.vitorpamplona.amethyst.Amethyst -import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.ScreenAuthAccount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.nip01Core.core.Address -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrlOrNull - -/** The owner pubkey of an addressable venue (`kind:pubkey:dTag`), or null for a bare channel id. */ -private fun venueOwnerPubkey(venueId: String): String? = Address.parse(venueId)?.pubKeyHex @Composable fun RelayAuthSubscription(accountViewModel: AccountViewModel) = RelayAuthSubscription(accountViewModel, Amethyst.instance.authCoordinator) @@ -45,51 +38,17 @@ fun RelayAuthSubscription( ) { val account = accountViewModel.account + // The per-account NIP-42 policy ledger now lives on Account (account.relayAuthLedger), so this + // only has to register the account itself. The coordinator decides + signs per account. val state = remember(accountViewModel) { ScreenAuthAccount(account) } - val ledger = - remember(accountViewModel) { - RelayAuthPermissionLedger( - store = Amethyst.instance.relayAuthPermissionStore, - globalPolicy = { account.settings.defaultRelayAuthPolicy.value }, - customToggles = { - RelayAuthCustomToggles( - myRelaysAndVenues = account.settings.relayAuthTrustMyRelaysAndVenues.value, - readFollows = account.settings.relayAuthTrustReadFollows.value, - messageFollows = account.settings.relayAuthTrustMessageFollows.value, - messageStrangers = account.settings.relayAuthTrustMessageStrangers.value, - ) - }, - isInMyRelayList = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.trustedRelays.flow.value - }, - isBlocked = { relayUrl -> - val normalized = relayUrl.normalizeRelayUrlOrNull() ?: return@RelayAuthPermissionLedger false - normalized in account.blockedRelayList.flow.value - }, - // Any follow list (kind 3, follow sets, etc.) counts as trusting the counterparty - // enough to reveal our identity to a relay that serves them. - isFollowed = { pubkey -> pubkey in account.allFollows.flow.value.authors }, - // A venue (public chat / community / live stream) is trusted if we've joined it, or - // its owner — the pubkey in a `kind:pubkey:dTag` address — is someone we follow. - isTrustedVenue = { venueId -> - venueId in account.publicChatList.flowSet.value || - venueId in account.communityList.flowSet.value || - venueOwnerPubkey(venueId)?.let { it in account.allFollows.flow.value.authors } == true - }, - ) - } - - DisposableEffect(state, ledger) { + DisposableEffect(state) { dataSource.subscribe(state) - dataSource.subscribeLedger(ledger) onDispose { dataSource.unsubscribe(state) - dataSource.unsubscribeLedger(ledger) } } } 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..441d09feb2 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 @@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope @@ -36,33 +39,19 @@ class ScreenAuthAccount( @Stable class AuthCoordinator( - client: INostrClient, + val client: INostrClient, scope: CoroutineScope, val promptBus: RelayAuthPromptBus = RelayAuthPromptBus(), ) { private val authWithAccounts = ListWithUniqueSetCache { it.account } - private val tempAccount by lazy { - NostrSignerSync() - } - - @Volatile private var relayLedgers: List = emptyList() - - fun subscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers + ledger } - } - - fun unsubscribeLedger(ledger: RelayAuthPermissionLedger) { - synchronized(this) { relayLedgers = relayLedgers - ledger } - } val receiver = RelayAuthenticator( client, scope, signWithAllLoggedInUsers = { 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. + // Reconstruct *why* this relay wants auth from what the shared client is doing with + // it. Built lazily so accounts that fail the first-party gate below don't pay for it. val context by lazy(LazyThreadSafetyMode.NONE) { RelayAuthContext( @@ -74,45 +63,83 @@ 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. - val outcome = - AuthDecisionResolver.resolve(currentLedgers.map { it.decide(context) }) { - promptBus.requestDecision(relayUrl, context.purposes) - } - outcome.remember?.let { decision -> - currentLedgers.firstOrNull()?.setDecision(relayUrl.url, decision) - } - val shouldAuth = outcome.shouldAuth - if (shouldAuth) { - // Remember why we granted this relay so the settings screen can explain it. - currentLedgers.firstOrNull()?.recordGrant(context) + // One socket is shared by every logged-in account, so an AUTH challenge is not tied + // to any single one of them. We answer PER ACCOUNT: an account only reveals its + // identity to a relay it has a first-party reason to be on (its own inbox/outbox + // traffic, or a relay it configured) AND its own ledger verdict allows it. This is + // what stops account B — or a throwaway key — being billed / de-anonymized on a + // relay only account A uses (the inbox.nostr.wine over-AUTH bug): unlike the old + // "any account ALLOWs → sign with everyone (else a random key)" path, a bystander + // account never signs, and there is no random-key fallback. + val signed = mutableListOf() + var askChoice: UserAuthChoice? = null - // 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 + authWithAccounts.distinctValues().forEach forEachAccount@{ screen -> + val account = screen.account + if (!account.signer.isWriteable()) return@forEachAccount + if (!isFirstParty(account, relayUrl)) return@forEachAccount + + val approve = + when (account.relayAuthLedger.decide(context)) { + RelayAuthVerdict.ALLOW -> true + RelayAuthVerdict.DENY -> false + RelayAuthVerdict.ASK -> { + // Prompt at most once per challenge; reuse the answer for any other + // account that also reaches ASK on this same relay. + val choice = askChoice ?: promptBus.requestDecision(relayUrl, context.purposes).also { askChoice = it } + when (choice) { + UserAuthChoice.ALLOW_ONCE -> true + UserAuthChoice.ALWAYS_ALLOW -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.ALLOW) + true + } + UserAuthChoice.BLOCK -> { + account.relayAuthLedger.setDecision(relayUrl.url, RelayAuthDecision.DENY) + false + } + UserAuthChoice.DISMISS -> false } - } else { - null } } - // Always auth, even with random keys - if (results.isNotEmpty()) results else listOf(tempAccount.sign(authTemplate)) - } else { - emptyList() + if (approve) { + // Remember why we granted this relay so the settings screen can explain it. + account.relayAuthLedger.recordGrant(context) + try { + signed.add(account.signer.sign(authTemplate)) + } catch (e: Exception) { + Log.e("AuthCoordinator", "Failed trying to authenticate a writeable account", e) + } + } } + + signed }, ) + /** + * True when [account] has a first-party reason to authenticate with [relayUrl] on the shared + * client: it is publishing its own event there, a subscription there is reading its own + * inbox/outbox (`#p` or `authors` names its pubkey), or the relay is in its own relay list. + * + * Merely *following* the counterparty of someone else's traffic is deliberately NOT first-party: + * that is exactly how a bystander account got dragged into a paid inbox relay's AUTH (the shared + * auth context carries the OTHER account's counterparties, evaluated against this account's + * follow graph). Reads of a followed author's outbox on an auth-gated relay this account doesn't + * use are therefore no longer auto-authed — a deliberate privacy-positive trade-off. + */ + private fun isFirstParty( + account: Account, + relayUrl: NormalizedRelayUrl, + ): Boolean = + RelayAuthFirstParty.hasReason( + me = account.pubKey, + relayUrl = relayUrl, + pendingEvents = client.activeOutboxEvents(relayUrl), + myRelays = account.trustedRelays.flow.value, + ) + fun destroy() { receiver.destroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt index c48bc6a6b7..af549f6f42 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/ListWithUniqueSetCache.kt @@ -54,6 +54,12 @@ class ListWithUniqueSetCache( return newSet } + /** One representative [T] per unique key — the first occurrence wins. */ + fun distinctValues(): List { + val seen = HashSet() + return list.get().filter { seen.add(key(it)) } + } + fun forEachSubscriber(action: (T) -> Unit) { list.get().forEach(action) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt new file mode 100644 index 0000000000..ac6bca8ffe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.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.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Whether a given account has a *first-party* reason to authenticate (NIP-42) with a relay on the + * shared [com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient]. Pure so the per-account + * signing gate can be tested without a live client, signer, or Compose. + * + * The socket is shared by every logged-in account, so "this relay wants auth" says nothing about + * *which* account should answer. An account should reveal its identity to a relay only when: + * - it is publishing its own event there ([pendingEvents] authored by it — e.g. delivering a DM to + * the recipient's inbox relay), or + * - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which + * is where its own inbox/outbox reads are routed anyway). + * + * Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is + * NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans + * it out to the union of everyone's relays, so account B's pubkey routinely rides a subscription to + * account A's paid relay. Trusting that dragged bystander accounts (e.g. into inbox.nostr.wine's + * AUTH, and its bill). Genuine own-inbox reads still qualify via [myRelays] — the relay serving them + * is by definition in the account's own list. + */ +object RelayAuthFirstParty { + fun hasReason( + me: HexKey, + relayUrl: NormalizedRelayUrl, + pendingEvents: List, + myRelays: Set, + ): Boolean { + if (pendingEvents.any { it.pubKey == me }) return true + return relayUrl in myRelays + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt index 5a244b6b1e..0b57347215 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relayauth/RelayAuthSettingsScreen.kt @@ -64,17 +64,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.Amethyst 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.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.LoadRelayAuthUser -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -105,8 +103,8 @@ fun RelayAuthSettingsScreen( nav: INav, ) { val account = accountViewModel.account - val store: DataStoreRelayAuthPermissionStore = Amethyst.instance.relayAuthPermissionStore - val ledger = remember { RelayAuthPermissionLedger(store, { account.settings.defaultRelayAuthPolicy.value }) } + val store: RelayAuthPermissionStore = account.relayAuthPermissions + val ledger = account.relayAuthLedger val scope = rememberCoroutineScope() val globalPolicy by account.settings.defaultRelayAuthPolicy.collectAsState() diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt new file mode 100644 index 0000000000..817fb6defd --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.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.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The per-account NIP-42 signing gate: on the shared client, an account signs a relay's AUTH only + * when the relay is in its own relay list, or it is publishing its own event there. The bug these + * lock down: an unpaid/bystander account being AUTH'd (and billed by inbox.nostr.wine) purely + * because another account uses that relay — including via a merged filter that names it. + */ +class RelayAuthFirstPartyTest { + private val relay = NormalizedRelayUrl("wss://inbox.nostr.wine/") + private val me = "a".repeat(64) + private val other = "b".repeat(64) + + private fun event(pubkey: String) = + Event( + id = "0".repeat(64), + pubKey = pubkey, + createdAt = 0, + kind = 1059, + tags = emptyArray(), + content = "", + sig = "", + ) + + @Test + fun aRelayNotInMyListWithNothingOfMineIsNotFirstParty() { + // The exact inbox.nostr.wine case: a relay another account uses, which a merged read filter + // names me on, but which is in none of my lists and where I publish nothing → must NOT sign. + assertFalse(RelayAuthFirstParty.hasReason(me, relay, emptyList(), emptySet())) + } + + @Test + fun publishingSomeoneElsesEventIsNotFirstParty() { + assertFalse(RelayAuthFirstParty.hasReason(me, relay, listOf(event(other)), emptySet())) + } + + @Test + fun aRelayIConfiguredIsFirstParty() { + // Own inbox/outbox reads qualify this way: the relay serving them is in my own relay list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, emptyList(), setOf(relay))) + } + + @Test + fun publishingMyOwnEventIsFirstParty() { + // Delivering my own DM/post to the recipient's relay, even one not in my list. + assertTrue(RelayAuthFirstParty.hasReason(me, relay, listOf(event(me)), emptySet())) + } +} From 6a528a742c37a27c9f4a9dc1de74876e49156807 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 12 Jul 2026 19:29:35 -0400 Subject: [PATCH 096/206] fix(relay-auth): show a relay's payment NOTIFY only under the billed account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay NOTIFY (payment prompt) went into a process-wide NotifyRequestsCache and DisplayNotifyMessages showed it under any account whose relay list contained the relay — so a prompt billed to Amethyst surfaced under Vitor, and stale entries re-appeared on every account switch. A NOTIFY doesn't reliably name a pubkey, so instead of parsing the message we correlate it with the AUTH that triggered it: a paid relay answers an unauthorized AUTH with `OK false …` right before the NOTIFY, and we signed that auth event. NotifyCoordinator remembers each auth event's signer, maps the failing OK back to it, and files the NOTIFY into THAT account's own Account.relayNotifications. Unattributable NOTIFYs are dropped. DisplayNotifyMessages now reads only the current account's cache. Also removes the now-unused app-wide RelayAuthPermissionStore singleton and wires NotifyCoordinator with the pubkey -> Account lookup. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/vitorpamplona/amethyst/AppModules.kt | 13 ++- .../compose/DisplayNotifyMessages.kt | 20 +--- .../notifyCommand/model/NotifyCoordinator.kt | 92 +++++++++++++++++-- 3 files changed, 96 insertions(+), 29 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34ade716a5..8b633a1fb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -79,7 +79,6 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator -import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState @@ -571,13 +570,13 @@ class AppModules( // Verifies and inserts in the cache from all relays, all subscriptions val cacheClientConnector = CacheClientConnector(client, cache) - // Show messages from the Relay and controls their dismissal - val notifyCoordinator = NotifyCoordinator(client) + // Show messages from the Relay and controls their dismissal. Attributes each NOTIFY to the + // account whose AUTH the relay rejected (accountsCache is declared below; the lambda reads it + // lazily at NOTIFY time, long after init). + val notifyCoordinator = NotifyCoordinator(client) { pubkey -> accountsCache.accounts.value[pubkey] } - // Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts. - val relayAuthPermissionStore by lazy { - DataStoreRelayAuthPermissionStore(appContext) - } + // Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions, + // backed by a file under accounts//), so there is no app-wide store here anymore. // Singleton stores for napplet permissions — DataStore v1 enforces one instance per file. val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt index 38f6d6566a..f442bc3ef0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/compose/DisplayNotifyMessages.kt @@ -21,22 +21,19 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl -import kotlinx.coroutines.flow.map @Composable fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, -) = DisplayNotifyMessages(Amethyst.instance.notifyCoordinator.requests, accountViewModel, nav) +) = DisplayNotifyMessages(accountViewModel.account.relayNotifications, accountViewModel, nav) @Composable fun DisplayNotifyMessages( @@ -44,17 +41,10 @@ fun DisplayNotifyMessages( accountViewModel: AccountViewModel, nav: INav, ) { - val flow = - remember(accountViewModel) { - requests.transientPaymentRequests.map { - it.filter { notifyMsg -> - notifyMsg.relayUrl in accountViewModel.account.dmRelayList.flow.value || - notifyMsg.relayUrl in accountViewModel.account.nip65RelayList.allFlowNoDefaults.value - } - } - } - - val openDialogMsg = flow.collectAsStateWithLifecycle(emptySet()) + // [requests] is THIS account's own cache. NotifyCoordinator only files a NOTIFY under the account + // whose AUTH the relay rejected, so there is no cross-account leak to filter out here — a prompt + // in this cache genuinely belongs to this account. + val openDialogMsg = requests.transientPaymentRequests.collectAsStateWithLifecycle() openDialogMsg.value.firstOrNull()?.let { request -> NotifyRequestDialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 404015a6b3..92b40d456e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -20,20 +20,98 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import android.util.LruCache +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayNotifier +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.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +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.utils.Log +import java.util.concurrent.ConcurrentHashMap +/** + * Routes relay `NOTIFY` payment prompts to the account they actually concern. + * + * A relay's NOTIFY does not reliably name a pubkey, so we can't parse the account out of the + * message. Instead we correlate it with the AUTH that triggered it: a paid relay answers an + * unauthorized AUTH with `OK false …` immediately followed by the NOTIFY. We *signed* + * that auth event, so [AuthCmd.event] tells us which account's key it was. We remember that per auth + * event, resolve the failing `OK` back to the signer, and drop the NOTIFY into THAT account's own + * [NotifyRequestsCache] ([Account.relayNotifications]). + * + * The cache is per account (not a process-wide singleton), so a prompt for account A can never + * surface under account B — and an unattributable NOTIFY is dropped rather than shown to the wrong + * account. This is the fix for the stale-global-cache leak where switching accounts re-surfaced + * another account's inbox.nostr.wine prompt. + */ class NotifyCoordinator( - client: INostrClient, + private val client: INostrClient, + private val accountForPubkey: (HexKey) -> Account?, ) { - val requests = NotifyRequestsCache() + companion object { + const val TAG = "NotifyCoordinator" + private const val AUTH_EVENT_CACHE = 256 + } - val receiver = - RelayNotifier(client) { message, relay -> - requests.addPaymentRequestIfNew(message, relay.url) + // authEventId -> the pubkey we signed it with. Bounded: only a handful of relays re-auth. + private val signerOfAuthEvent = LruCache(AUTH_EVENT_CACHE) + + // relay -> pubkey of the auth the relay most recently rejected there (the one it will bill). + private val billedPubkeyAt = ConcurrentHashMap() + + private val listener = + object : RelayConnectionListener { + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is AuthCmd) { + signerOfAuthEvent.put(cmd.event.id, cmd.event.pubKey) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + is OkMessage -> + if (!msg.success) { + signerOfAuthEvent.get(msg.eventId)?.let { billedPubkeyAt[relay.url] = it } + } + is NotifyMessage -> route(relay.url, msg.message) + else -> {} + } + } } + private fun route( + relay: NormalizedRelayUrl, + message: String, + ) { + // Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution. + // An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than + // risk surfacing it under the wrong account. + val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) + account?.relayNotifications?.addPaymentRequestIfNew(message, relay) + } + + init { + Log.d(TAG, "Init, Subscribe") + client.addConnectionListener(listener) + } + fun destroy() { - receiver.destroy() + Log.d(TAG, "Destroy, Unsubscribe") + client.removeConnectionListener(listener) } } From ba0e6a0391f384454d063d6c3aca095753a9171d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:21:44 +0000 Subject: [PATCH 097/206] 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 8ee90907ca56df611c3c79d21b6d16d82755f9fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:26:27 +0000 Subject: [PATCH 098/206] feat: nickname users via NIP-85 contact cards signed by the account key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds petnames/nicknames per https://github.com/nostr-protocol/nips/pull/761, reusing the kind:30382 contact card already used for WoT scores — a card only acts as a nickname when signed by the account's main key. Petname and summary are always stored in the card's NIP-44 encrypted content. quartz: - ContactCardEvent.updatePetNameAndSummary edits both fields in the encrypted private tags, strips stray public copies, preserves every other tag - TagArray.petName()/summary() parsers for decrypted tag lists + tests commons: - ContactCardDecryptionCache: LRU NIP-44 decrypt cache for own cards - ContactCardsState: account-scoped access to the account's own cards, petname flow per target user, create/update entry point amethyst: - Account.updateContactCardPetName publishes through the extended outbox relays (NIP-65 write + private outbox + local + broadcast) and the login subscription now downloads the account's own kind:30382s from its relays - petname renders instead of the display name in usernames, profile header, chats and @mentions (observeUserPetName) - Edit-nickname action + dialog on the profile actions menu Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 18 +++ .../FilterAccountInfoAndListsFromKey.kt | 17 ++ .../reqCommand/user/UserObservers.kt | 29 +++- .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 7 +- .../ui/screen/loggedIn/AccountViewModel.kt | 6 + .../loggedIn/chats/feed/DrawAuthorInfo.kt | 6 +- .../profile/header/DrawAdditionalInfo.kt | 7 +- .../profile/header/EditNicknameDialog.kt | 115 ++++++++++++++ .../profile/header/UserProfileDropDownMenu.kt | 22 +++ amethyst/src/main/res/values/strings.xml | 7 + .../ContactCardDecryptionCache.kt | 47 ++++++ .../ContactCardsState.kt | 127 +++++++++++++++ .../users/ContactCardEvent.kt | 40 +++++ .../users/TagArrayExt.kt | 30 ++++ .../ContactCardPetNameTest.kt | 147 ++++++++++++++++++ 17 files changed, 620 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.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 798aef0e4a..b108f69053 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecr import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult @@ -418,6 +420,9 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) + val contactCardDecryptionCache = ContactCardDecryptionCache(signer) + val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, scope) + val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) @@ -3771,6 +3776,19 @@ class Account( sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) } + /** + * Nicknames a user by publishing the account's kind:30382 contact card about + * them, with the petname and summary NIP-44 encrypted in the content. `null` + * clears a field. Goes out through the account's extended outbox relays. + */ + suspend fun updateContactCardPetName( + pubkeyHex: HexKey, + petName: String?, + summary: String?, + ) { + sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) + } + suspend fun showUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex)) 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..967bbd4f45 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 @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -93,6 +94,12 @@ val AccountInfoAndListsFromKeyKinds2 = NutzapInfoEvent.KIND, ) +// The account's own kind:30382 contact cards (nicknames/petnames for other +// users, NIP-44 encrypted). Addressable: one card per target user, so this is a +// collection rather than a single replaceable — kept out of the small-limit +// filters above and given its own filter with a larger limit. +val AccountContactCardKinds = listOf(ContactCardEvent.KIND) + val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) @@ -124,6 +131,16 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = AccountContactCardKinds, + authors = listOf(pubkey), + limit = 500, + since = since, + ), + ), RelayBasedFilter( relay = relay, filter = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 447b4f68d8..03f9099256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -43,6 +43,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map @@ -61,18 +62,34 @@ fun observeUserName( val flow = remember(user) { - user - .metadata() - .flow - .map { - it?.info?.bestName() ?: user.pubkeyDisplayHex() - }.distinctUntilChanged() + combine( + user.metadata().flow, + accountViewModel.account.contactCards.petNameFlow(user), + ) { info, petName -> + petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() + }.distinctUntilChanged() } // Subscribe in the LocalCache for changes that arrive in the device return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) } +/** + * The nickname (NIP-85 petname) the logged-in account gave this user through + * its own contact card, decrypted from the card's content. Null when the + * account never nicknamed this user. Per the spec, when present it should be + * rendered instead of the user's display name. + */ +@Composable +fun observeUserPetName( + user: User, + accountViewModel: AccountViewModel, +): State { + val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } + + return flow.collectAsStateWithLifecycle(null) +} + @OptIn(ExperimentalCoroutinesApi::class) @Composable fun observeUserAboutMe( 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..d0cb60818b 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 @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -298,9 +299,10 @@ fun RenderUserAsClickableText( nav: INav, ) { val userState by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CreateClickableTextWithEmoji( - clickablePart = "@" + (userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, 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..f25a6d9049 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 @@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown @@ -1010,11 +1011,12 @@ private fun DisplayUserFromTag( nav: INav, ) { val meta by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { CreateClickableTextWithEmoji( - clickablePart = remember(meta) { it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, + clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index e7f64cd167..f80ce17b7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -105,11 +106,13 @@ fun UsernameDisplay( accountViewModel: AccountViewModel, ) { val userMetadata by observeUserInfo(baseUser, accountViewModel) + val petName by observeUserPetName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { - val name = it?.info?.bestName() + // the account's own nickname for this user wins over the user's metadata + val name = petName ?: it?.info?.bestName() if (name != null) { - UserDisplay(name, it.tags, weight, fontWeight, textColor, textAlign) + UserDisplay(name, it?.tags, weight, fontWeight, textColor, textAlign) } else { NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign) } 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 8e06823ba4..b554cf1a71 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 @@ -1704,6 +1704,12 @@ class AccountViewModel( fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) } + fun updateContactCardPetName( + user: User, + petName: String?, + summary: String?, + ) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) } + fun hide(word: String) = launchSigner { account.hideWord(word) } fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index 2488506058..b780b707be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon @@ -58,13 +59,14 @@ private fun WatchAndDisplayUser( nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) + val petName by observeUserPetName(author, accountViewModel) UserDisplayNameLayout( picture = { InnerUserPicture( userHex = author.pubkeyHex, userPicture = userState?.info?.picture, - userName = userState?.info?.bestName(), + userName = petName ?: userState?.info?.bestName(), size = Size20dp, modifier = Modifier, accountViewModel = accountViewModel, @@ -81,7 +83,7 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(), + text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), tags = userState?.tags ?: EmptyTagList, maxLines = 1, fontWeight = FontWeight.Bold, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index fd9a2d1d5f..4a785f0b19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText @@ -106,7 +107,11 @@ fun DrawAdditionalInfo( val scope = rememberCoroutineScope() val identities by externalIdentities.identities.collectAsStateWithLifecycle() - val displayName = user.info.bestName() + val petName by observeUserPetName(baseUser, accountViewModel) + + // the nickname the account gave this user wins over the profile's own name; + // the "@name" line below keeps the real handle visible for disambiguation + val displayName = petName ?: user.info.bestName() val ui = accountViewModel.settings.uiSettingsFlow val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt new file mode 100644 index 0000000000..b2c9a6dfa6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.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.profile.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * Edits the nickname (petname) and private note (summary) the account keeps for + * [user] in its own kind:30382 contact card. Both fields are saved NIP-44 + * encrypted, so only this account can read them. Blank fields clear the value. + */ +@Composable +fun EditNicknameDialog( + user: User, + onDismiss: () -> Unit, + accountViewModel: AccountViewModel, +) { + val nickname = remember { mutableStateOf("") } + val summary = remember { mutableStateOf("") } + + // Prefill with the card's current encrypted values, if any. + LaunchedEffect(user) { + nickname.value = accountViewModel.account.contactCards.petName(user.pubkeyHex) ?: "" + summary.value = accountViewModel.account.contactCards.summary(user.pubkeyHex) ?: "" + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringRes(R.string.nickname_dialog_title)) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringRes(R.string.nickname_dialog_explainer), + color = MaterialTheme.colorScheme.placeholderText, + ) + TextField( + value = nickname.value, + onValueChange = { nickname.value = it }, + singleLine = true, + label = { + Text(text = stringRes(R.string.nickname_label)) + }, + modifier = Modifier, + ) + TextField( + value = summary.value, + onValueChange = { summary.value = it }, + label = { + Text(text = stringRes(R.string.nickname_summary_label)) + }, + ) + } + }, + confirmButton = { + Button( + onClick = { + accountViewModel.updateContactCardPetName( + user = user, + petName = nickname.value.trim().ifBlank { null }, + summary = summary.value.trim().ifBlank { null }, + ) + onDismiss() + }, + ) { + Text(stringRes(R.string.save)) + } + }, + dismissButton = { + Button( + onClick = onDismiss, + ) { + Text(stringRes(R.string.cancel)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 0fc0096a51..71e9ac8a38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.Intent import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -45,6 +47,16 @@ fun UserProfileDropDownMenu( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { + val isNicknameDialogOpen = remember { mutableStateOf(false) } + + if (isNicknameDialogOpen.value) { + EditNicknameDialog( + user = user, + onDismiss = { isNicknameDialogOpen.value = false }, + accountViewModel = accountViewModel, + ) + } + if (!popupExpanded) return M3ActionDialog( @@ -88,6 +100,16 @@ fun UserProfileDropDownMenu( // Moderation section (if not self) if (accountViewModel.userProfile() != user) { + M3ActionSection { + M3ActionRow( + icon = MaterialSymbols.Edit, + text = stringRes(R.string.edit_nickname), + ) { + isNicknameDialogOpen.value = true + onDismiss() + } + } + M3ActionSection { if (accountViewModel.account.isHidden(user)) { M3ActionRow( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..4b892769e2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3543,6 +3543,13 @@ Playback Auto + + Edit nickname + Nickname + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. + Nickname + Private note about this user + Cast to device Stop casting diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt new file mode 100644 index 0000000000..e69a38f9b0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.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.amethyst.commons.model.nip85TrustedAssertions + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.petName +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.summary + +/** + * Decrypts and caches the NIP-44 private tags of the account's own kind:30382 + * contact cards. Petname and summary always live in the encrypted part, so + * reading them requires the account's main key ([signer]); cards signed by any + * other key never decrypt here. + */ +class ContactCardDecryptionCache( + val signer: NostrSigner, +) { + val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) + + fun cachedPetName(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).petName() + + fun cachedSummary(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).summary() + + suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() + + suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt new file mode 100644 index 0000000000..bc6b0839cd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -0,0 +1,127 @@ +/* + * 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.nip85TrustedAssertions + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest + +/** + * The account's own kind:30382 contact cards — one card per target user, signed + * by the account's main key. This is how the user nicknames other users: the + * petname and summary always live in the card's NIP-44 encrypted content. + * + * The same kind is used by trust providers for WoT scores; those cards are + * ignored here because everything is keyed on the card author being the + * account itself ([signer]'s pubkey). + */ +@Stable +class ContactCardsState( + val signer: NostrSigner, + val cache: ICacheProvider, + val decryptionCache: ContactCardDecryptionCache, + val scope: CoroutineScope, +) { + private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } + + fun createCardAddress(target: HexKey): Address = ContactCardEvent.createAddress(signer.pubKey, target) + + fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target)) + + fun getCard(target: HexKey): ContactCardEvent? = getCardNote(target).event as? ContactCardEvent + + /** + * The account's own card about [target], as attached to the target user's + * [UserCardsCache] when the event is consumed. Reads from the existing + * received-cards map so displaying users without a card allocates nothing new. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun myCardFlow(target: User): Flow = + target + .cards() + .receivedCards + .map { it[accountUser] } + .distinctUntilChanged() + .flatMapLatest { note -> + note + ?.flow() + ?.metadata + ?.stateFlow + ?.map { it.note.event as? ContactCardEvent } + ?: flowOf(null) + } + + /** The petname the account gave [target], decrypted from the card's content. */ + @OptIn(ExperimentalCoroutinesApi::class) + fun petNameFlow(target: User): Flow = + myCardFlow(target) + .mapLatest { card -> card?.let { decryptionCache.petName(it) } } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } + + suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } + + /** + * Builds the new signed card for [target] with the given petname and summary + * (both stored NIP-44 encrypted; `null` clears the field), preserving every + * other tag of an existing card. The caller is responsible for publishing it. + */ + suspend fun updatePetNameAndSummary( + target: HexKey, + petName: String?, + summary: String?, + ): ContactCardEvent { + val existing = getCard(target) + return if (existing != null) { + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = existing, + petName = petName, + summary = summary, + signer = signer, + ) + } else { + ContactCardEvent.create( + targetUser = target, + petName = petName, + summary = summary, + signer = signer, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index 24b53cac48..dff8e70ba7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -26,11 +26,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag @@ -144,5 +146,43 @@ class ContactCardEvent( val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) return signer.sign(createdAt, KIND, publicTags, encryptedContent) } + + /** + * Replaces the petname and summary of an existing card, keeping every other + * public and private tag intact. Both fields always live in the NIP-44 + * encrypted content — any stray public copy is stripped. A `null` value + * removes the field from the card. + */ + suspend fun updatePetNameAndSummary( + earlierVersion: ContactCardEvent, + petName: String? = null, + summary: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): ContactCardEvent { + val privateTags = + earlierVersion.privateTags(signer) + ?: throw SignerExceptions.UnauthorizedDecryptionException() + + var newPrivateTags = + privateTags + .remove(arrayOf(PetNameTag.TAG_NAME)) + .remove(arrayOf(SummaryTag.TAG_NAME)) + + petName?.let { newPrivateTags = newPrivateTags.plus(PetNameTag.assemble(it)) } + summary?.let { newPrivateTags = newPrivateTags.plus(SummaryTag.assemble(it)) } + + val newPublicTags = + earlierVersion.tags + .remove(arrayOf(PetNameTag.TAG_NAME)) + .remove(arrayOf(SummaryTag.TAG_NAME)) + + return signer.sign( + createdAt, + KIND, + newPublicTags, + PrivateTagsInContent.encryptNip44(newPrivateTags, signer), + ) + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt new file mode 100644 index 0000000000..2af2ee5722 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -0,0 +1,30 @@ +/* + * 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.nip85TrustedAssertions.users + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag + +fun TagArray.petName() = fastFirstNotNullOfOrNull(PetNameTag::parse) + +fun TagArray.summary() = fastFirstNotNullOfOrNull(SummaryTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt new file mode 100644 index 0000000000..4617334369 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -0,0 +1,147 @@ +/* + * 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.experimental.nip85TrustedAssertions + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ContactCardPetNameTest { + val signer = NostrSignerInternal(KeyPair()) + val targetUser = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411" + + private suspend fun ContactCardEvent.privatePetName() = privateTags(signer)?.firstNotNullOfOrNull(PetNameTag::parse) + + private suspend fun ContactCardEvent.privateSummary() = privateTags(signer)?.firstNotNullOfOrNull(SummaryTag::parse) + + @Test + fun createKeepsPetNameAndSummaryEncrypted() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob from work", + summary = "Met at the conference", + signer = signer, + ) + + assertEquals(targetUser, card.aboutUser()) + + // never in the public tags + assertNull(card.petName()) + assertNull(card.summary()) + + // always in the encrypted content + assertEquals("Bob from work", card.privatePetName()) + assertEquals("Met at the conference", card.privateSummary()) + } + + @Test + fun updateReplacesPetNameAndKeepsOtherPrivateTags() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob", + summary = "old summary", + signer = signer, + privateInitializer = { add(arrayOf("t", "friend")) }, + publicInitializer = { add(arrayOf("n", "follow")) }, + ) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bobby", + summary = "new summary", + signer = signer, + ) + + assertEquals(targetUser, updated.aboutUser()) + assertEquals("Bobby", updated.privatePetName()) + assertEquals("new summary", updated.privateSummary()) + + // other tags survive on both sides + assertTrue(updated.privateTags(signer)!!.any { it.size > 1 && it[0] == "t" && it[1] == "friend" }) + assertTrue(updated.tags.any { it.size > 1 && it[0] == "n" && it[1] == "follow" }) + + // still nothing leaked publicly + assertNull(updated.petName()) + assertNull(updated.summary()) + } + + @Test + fun updateWithNullsClearsBothFields() = + runTest { + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob", + summary = "summary", + signer = signer, + ) + + val cleared = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = null, + summary = null, + signer = signer, + ) + + assertNull(cleared.privatePetName()) + assertNull(cleared.privateSummary()) + } + + @Test + fun updateStripsLegacyPublicCopies() = + runTest { + // a card that (incorrectly) carries public petname/summary tags + val card = + ContactCardEvent.create( + targetUser = targetUser, + signer = signer, + publicInitializer = { + add(PetNameTag.assemble("public bob")) + add(SummaryTag.assemble("public summary")) + }, + ) + assertEquals("public bob", card.petName()) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "private bob", + signer = signer, + ) + + assertNull(updated.petName()) + assertNull(updated.summary()) + assertEquals("private bob", updated.privatePetName()) + } +} From 718e2cba0bdf1d8a8f437ea08d2c5b73217ba58d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:32:32 +0000 Subject: [PATCH 099/206] feat(ui): move PoW, OTS and location pills to the note header's first line Restyle DisplayPoW, DisplayOts and DisplayLocation as compact squared chips via a new shared HeaderPill composable, matching the relay information pills in the NIP-29 chat headers (which now reuse the same composable). Move the three pills from SecondUserInfoRow to FirstUserInfoRow in NoteCompose, and shrink the secondary markers on that line (boosted, hashtag, community, draft, edited, expiration, pinned, private-rumor) to 12sp/smaller icons since the row now carries more information. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 41 +++++----- .../amethyst/ui/note/elements/BoostedMark.kt | 2 + .../ui/note/elements/DisplayCommunity.kt | 3 + .../ui/note/elements/DisplayEditStatus.kt | 2 + .../ui/note/elements/DisplayHashtags.kt | 3 + .../ui/note/elements/DisplayLocation.kt | 36 +++------ .../amethyst/ui/note/elements/DisplayOts.kt | 53 ++++++------- .../amethyst/ui/note/elements/DisplayPoW.kt | 46 ++--------- .../amethyst/ui/note/elements/HeaderPill.kt | 79 +++++++++++++++++++ .../chats/rooms/ChatroomHeaderCompose.kt | 37 ++------- 10 files changed, 155 insertions(+), 147 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.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 9939907e47..6286a1a484 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 @@ -761,7 +761,6 @@ fun InnerNoteWithReactions( if (showSecondRow) { SecondUserInfoRow( baseNote, - editState, accountViewModel, nav, ) @@ -883,7 +882,6 @@ fun NoteBody( if (showSecondRow) { SecondUserInfoRow( baseNote, - editState, accountViewModel, nav, ) @@ -1737,7 +1735,6 @@ fun ReplyNoteComposition( @Composable fun SecondUserInfoRow( note: Note, - editState: State>, accountViewModel: AccountViewModel, nav: INav, ) { @@ -1756,25 +1753,11 @@ fun SecondUserInfoRow( } } - val geo = remember(noteEvent) { noteEvent.geoHashOrScope() } - if (geo != null) { - Spacer(StdHorzSpacer) - DisplayLocation(geo, accountViewModel, nav) - } - val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { Spacer(StdHorzSpacer) DisplayReward(baseReward, note, accountViewModel, nav) } - - val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } - if (pow != null) { - Spacer(StdHorzSpacer) - DisplayPoW(pow) - } - - DisplayOtsIfInOriginal(note, editState, accountViewModel) } } @@ -1786,13 +1769,14 @@ fun DisplayExpiration(expirationDate: Long) { Icon( symbol = MaterialSymbols.Timer, contentDescription = stringRes(R.string.expiration_date_label), - modifier = Modifier.padding(start = 5.dp).size(15.dp), + modifier = Modifier.padding(start = 5.dp).size(12.dp), tint = MaterialTheme.colorScheme.placeholderText, ) val context = LocalContext.current Text( text = timeAheadNoDot(expirationDate, context), color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, maxLines = 1, modifier = Modifier.padding(start = 3.dp), ) @@ -1819,6 +1803,7 @@ fun DisplayDraft() { Text( "Draft", fontWeight = FontWeight.Bold, + fontSize = Font12SP, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, modifier = HalfStartPadding, @@ -1919,6 +1904,22 @@ fun FirstUserInfoRow( PinnedMark() } + val noteEvent = baseNote.event + + val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } + if (geo != null) { + Spacer(StdHorzSpacer) + DisplayLocation(geo, accountViewModel, nav) + } + + val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } + if (pow != null) { + Spacer(StdHorzSpacer) + DisplayPoW(pow) + } + + DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) + Expiration(baseNote) JumpToParentReplyButton(baseNote, accountViewModel, nav) @@ -1938,7 +1939,7 @@ fun PinnedMark() { Icon( symbol = MaterialSymbols.PushPin, contentDescription = stringRes(R.string.pinned_notes), - modifier = Modifier.padding(start = 5.dp).size(16.dp), + modifier = Modifier.padding(start = 5.dp).size(14.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -1948,7 +1949,7 @@ fun PrivateRumorMark() { Icon( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), - modifier = Modifier.padding(start = 5.dp).size(16.dp), + modifier = Modifier.padding(start = 5.dp).size(14.dp), tint = MaterialTheme.colorScheme.placeholderText, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index b91bdbeb81..d214b3391c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -34,6 +35,7 @@ fun BoostedMark() { Text( stringRes(id = R.string.boosted), fontWeight = FontWeight.Bold, + fontSize = Font12SP, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, modifier = HalfStartPadding, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 3e32487fc8..404688cc03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -34,6 +35,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo 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.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress @@ -64,6 +66,7 @@ private fun DisplayCommunity( ClickableTextColor( label, + style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 8ebe574745..ee31e07dc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -50,6 +51,7 @@ fun DisplayEditStatus(editState: EditState) { LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.placeholderText, fontWeight = FontWeight.Bold, + fontSize = Font12SP, ), maxLines = 1, modifier = HalfStartPadding.clickable { editState.nextModification() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cc4f929afb..b9f73c3367 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,6 +39,7 @@ import com.vitorpamplona.amethyst.model.Note 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.theme.Font12SP import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -82,6 +84,7 @@ private fun DisplayTagList( ) { ClickableTextColor( "#$firstTag", + style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index e6d942c620..cf240cb7f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -20,20 +20,17 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withLink +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.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Font14SP +/** + * Compact pill showing the geohash a note is scoped to, resolved to a city + * name. Tapping it opens the geohash feed. Sits inline in note headers. + */ @Composable fun DisplayLocation( geohashStr: String, @@ -41,25 +38,10 @@ fun DisplayLocation( nav: INav, ) { LoadCityName(geohashStr) { cityName -> - Text( - text = - buildAnnotatedString { - withLink( - LinkAnnotation.Clickable("cityname") { nav.nav(Route.Geohash(geohashStr)) }, - ) { - append(cityName) - } - }, - style = - LocalTextStyle.current.copy( - color = - MaterialTheme.colorScheme.primary.copy( - alpha = 0.52f, - ), - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.LocationOn, + text = cityName, + onClick = { nav.nav(Route.Geohash(geohashStr)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index ec5b9bb06b..fdf5f6890c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -20,27 +20,27 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.components.buildLinkString +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import java.text.SimpleDateFormat import java.util.Date +/** + * Compact pill showing the note's NIP-03 OpenTimestamps proof: how long ago + * the note is proven to have existed. Tapping it explains the proof and shows + * the attested date. Sits inline in note headers. + */ @Composable fun DisplayOts( note: Note, @@ -60,31 +60,26 @@ fun DisplayOts( ) } - Text( - text = - buildLinkString(stringRes(R.string.existed_since, timeStr)) { - accountViewModel.toastManager.toast( - R.string.ots_info_title, - R.string.ots_info_description, - SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)), - ) - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - ), - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.CheckCircle, + text = stringRes(R.string.existed_since, timeStr), + modifier = HalfStartPadding, + contentDescription = stringRes(R.string.ots_info_title), + onClick = { + accountViewModel.toastManager.toast( + R.string.ots_info_title, + R.string.ots_info_description, + SimpleDateFormat.getDateTimeInstance().format(Date(unixtimestamp * 1000)), + ) + }, ) }, whenPending = { - Text( - stringRes(id = R.string.timestamp_pending_short), - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - fontWeight = FontWeight.Bold, - maxLines = 1, + HeaderPill( + symbol = MaterialSymbols.Schedule, + text = stringRes(id = R.string.timestamp_pending_short), + modifier = HalfStartPadding, + contentDescription = stringRes(R.string.ots_info_title), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index e257452632..64aad9648a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -20,24 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.background -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.Text import androidx.compose.runtime.Composable -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.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -51,34 +36,15 @@ fun DisplayPoWPreview() { } /** - * Compact pill showing the proof of work a received note carries: a bolt plus + * Compact pill showing the proof of work a received note carries: a gear plus * the difficulty in leading zero bits. Sits inline in note headers, so it * stays at text height. */ @Composable fun DisplayPoW(pow: Int) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = - Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .padding(horizontal = 5.dp, vertical = 1.dp), - ) { - Icon( - symbol = MaterialSymbols.Manufacturing, - contentDescription = stringRes(R.string.pow_settings_title), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(10.dp), - ) - Text( - text = pow.toString(), - color = MaterialTheme.colorScheme.onSecondaryContainer, - fontSize = 10.sp, - lineHeight = 10.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) - } + HeaderPill( + symbol = MaterialSymbols.Manufacturing, + text = pow.toString(), + contentDescription = stringRes(R.string.pow_settings_title), + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt new file mode 100644 index 0000000000..2e70a1f398 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.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.amethyst.ui.note.elements + +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.MaterialSymbol + +/** + * Compact squared chip for note-header metadata (PoW, OTS, location, relay + * hosts, ...). Matches the relay information pills used in the NIP-29 chat + * headers so all header badges share one look: a small icon plus a short + * label on a secondary-container surface with slightly rounded corners. + */ +@Composable +fun HeaderPill( + symbol: MaterialSymbol, + text: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + onClick: (() -> Unit)? = null, +) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(11.dp), + ) + Text( + text = text, + 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 442e49cf2d..976cbe3032 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,20 +20,14 @@ */ 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 @@ -78,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent +import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -453,31 +448,11 @@ private fun RelayNameChip( label: String, onClick: () -> Unit, ) { - 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, - ) - } - } + HeaderPill( + symbol = MaterialSymbols.Dns, + text = label, + onClick = onClick, + ) } @Composable From ebeecd1ee7d8e7f4c01c3c1145ebab187a68607a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:39:59 +0000 Subject: [PATCH 100/206] feat: custom emojis in contact card petnames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog autocompletes from the account's emoji packs, and the emoji mappings for any shortcode used are embedded in the card's NIP-44 encrypted content next to the petname — so even the emoji set stays private. Renderers resolve the petname's shortcodes against the card's decrypted tags instead of the profile's metadata tags. - quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale and keeps it out of the public tags; round-trip test added - commons: PetName(name, tags) holder with content equality, decryption cache returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves :codes: against the selected packs - amethyst: Account embeds resolved emoji tags on save; all petname render sites pass the card tags to the WithEmoji composables; nickname dialog gets the : emoji autocomplete via EmojiSuggestionState Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 9 ++- .../reqCommand/user/UserObservers.kt | 12 +-- .../amethyst/ui/components/ClickableRoute.kt | 4 +- .../amethyst/ui/components/RichTextViewer.kt | 4 +- .../amethyst/ui/note/UsernameDisplay.kt | 7 +- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 6 +- .../profile/header/DrawAdditionalInfo.kt | 4 +- .../profile/header/EditNicknameDialog.kt | 76 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 2 +- .../model/nip30CustomEmojis/EmojiPackState.kt | 17 +++++ .../ContactCardDecryptionCache.kt | 11 +++ .../ContactCardsState.kt | 21 +++-- .../model/nip85TrustedAssertions/PetName.kt | 41 ++++++++++ .../users/ContactCardEvent.kt | 16 +++- .../ContactCardPetNameTest.kt | 31 ++++++++ 15 files changed, 216 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.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 b108f69053..103170391b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -3778,15 +3778,18 @@ class Account( /** * Nicknames a user by publishing the account's kind:30382 contact card about - * them, with the petname and summary NIP-44 encrypted in the content. `null` - * clears a field. Goes out through the account's extended outbox relays. + * them, with the petname and summary NIP-44 encrypted in the content. Any + * `:shortcode:` from the account's emoji packs gets its NIP-30 emoji mapping + * embedded (also encrypted) so the nickname renders with custom emojis. + * `null` clears a field. Goes out through the account's extended outbox relays. */ suspend fun updateContactCardPetName( pubkeyHex: HexKey, petName: String?, summary: String?, ) { - sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) + val emojis = emoji.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) + sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary, emojis)) } suspend fun showUser(pubkeyHex: HexKey) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 03f9099256..768fe0996b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.PetName import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.NoteState @@ -66,7 +67,7 @@ fun observeUserName( user.metadata().flow, accountViewModel.account.contactCards.petNameFlow(user), ) { info, petName -> - petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() + petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() }.distinctUntilChanged() } @@ -76,15 +77,16 @@ fun observeUserName( /** * The nickname (NIP-85 petname) the logged-in account gave this user through - * its own contact card, decrypted from the card's content. Null when the - * account never nicknamed this user. Per the spec, when present it should be - * rendered instead of the user's display name. + * its own contact card, decrypted from the card's content, with the card's + * tags so `:shortcode:` custom emojis resolve. Null when the account never + * nicknamed this user. Per the spec, when present it should be rendered + * instead of the user's display name. */ @Composable fun observeUserPetName( user: User, accountViewModel: AccountViewModel, -): State { +): State { val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } return flow.collectAsStateWithLifecycle(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 d0cb60818b..b5a61c19bb 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 @@ -302,12 +302,12 @@ fun RenderUserAsClickableText( val petName by observeUserPetName(baseUser, accountViewModel) CreateClickableTextWithEmoji( - clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (petName?.petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = userState?.tags ?: EmptyTagList, + tags = petName?.tags ?: userState?.tags ?: EmptyTagList, ) } 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 f25a6d9049..56ce3b6411 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 @@ -1016,11 +1016,11 @@ private fun DisplayUserFromTag( CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { CreateClickableTextWithEmoji( - clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, + clickablePart = remember(meta, petName) { petName?.petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = it?.tags, + tags = petName?.tags ?: it?.tags, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index f80ce17b7d..c36934bbb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -109,10 +109,11 @@ fun UsernameDisplay( val petName by observeUserPetName(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { - // the account's own nickname for this user wins over the user's metadata - val name = petName ?: it?.info?.bestName() + // the account's own nickname for this user wins over the user's metadata; + // its custom emojis resolve against the contact card's tags, not the profile's + val name = petName?.petName ?: it?.info?.bestName() if (name != null) { - UserDisplay(name, it?.tags, weight, fontWeight, textColor, textAlign) + UserDisplay(name, petName?.tags ?: it?.tags, weight, fontWeight, textColor, textAlign) } else { NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index b780b707be..c05a352e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -66,7 +66,7 @@ private fun WatchAndDisplayUser( InnerUserPicture( userHex = author.pubkeyHex, userPicture = userState?.info?.picture, - userName = petName ?: userState?.info?.bestName(), + userName = petName?.petName ?: userState?.info?.bestName(), size = Size20dp, modifier = Modifier, accountViewModel = accountViewModel, @@ -83,8 +83,8 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), - tags = userState?.tags ?: EmptyTagList, + text = petName?.petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), + tags = petName?.tags ?: userState?.tags ?: EmptyTagList, maxLines = 1, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 4a785f0b19..33c17b342a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -111,7 +111,7 @@ fun DrawAdditionalInfo( // the nickname the account gave this user wins over the profile's own name; // the "@name" line below keeps the real handle visible for disambiguation - val displayName = petName ?: user.info.bestName() + val displayName = petName?.petName ?: user.info.bestName() val ui = accountViewModel.settings.uiSettingsFlow val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle() @@ -125,7 +125,7 @@ fun DrawAdditionalInfo( ) { CreateTextWithEmoji( text = displayName, - tags = user.tags, + tags = petName?.tags ?: user.tags, fontWeight = FontWeight.Bold, fontSize = 22.sp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt index b2c9a6dfa6..dd133fa887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf @@ -34,7 +37,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.text.currentWord +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @@ -43,6 +52,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText * Edits the nickname (petname) and private note (summary) the account keeps for * [user] in its own kind:30382 contact card. Both fields are saved NIP-44 * encrypted, so only this account can read them. Blank fields clear the value. + * + * Typing `:` offers the account's NIP-30 custom emojis; the mappings for any + * shortcode used are embedded (also encrypted) so the nickname renders with them. */ @Composable fun EditNicknameDialog( @@ -50,13 +62,30 @@ fun EditNicknameDialog( onDismiss: () -> Unit, accountViewModel: AccountViewModel, ) { - val nickname = remember { mutableStateOf("") } - val summary = remember { mutableStateOf("") } + val nickname = rememberTextFieldState() + val summary = rememberTextFieldState() + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account) } + // which field the emoji autocomplete should insert into + val emojiTarget = remember { mutableStateOf(null) } + + // keeps the account's selected emoji packs loaded while the dialog is open + WatchAndLoadMyEmojiList(accountViewModel) // Prefill with the card's current encrypted values, if any. LaunchedEffect(user) { - nickname.value = accountViewModel.account.contactCards.petName(user.pubkeyHex) ?: "" - summary.value = accountViewModel.account.contactCards.summary(user.pubkeyHex) ?: "" + accountViewModel.account.contactCards + .petName(user.pubkeyHex) + ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } + accountViewModel.account.contactCards + .summary(user.pubkeyHex) + ?.let { summary.setTextAndPlaceCursorAtEnd(it) } + } + + fun watchEmojiIn(field: TextFieldState) { + emojiTarget.value = field + if (field.selection.collapsed) { + emojiSuggestions.processCurrentWord(field.currentWord()) + } } AlertDialog( @@ -72,22 +101,33 @@ fun EditNicknameDialog( text = stringRes(R.string.nickname_dialog_explainer), color = MaterialTheme.colorScheme.placeholderText, ) - TextField( - value = nickname.value, - onValueChange = { nickname.value = it }, + ThinPaddingTextField( + state = nickname, + onTextChanged = { watchEmojiIn(nickname) }, singleLine = true, label = { Text(text = stringRes(R.string.nickname_label)) }, - modifier = Modifier, ) - TextField( - value = summary.value, - onValueChange = { summary.value = it }, + ThinPaddingTextField( + state = summary, + onTextChanged = { watchEmojiIn(summary) }, label = { Text(text = stringRes(R.string.nickname_summary_label)) }, ) + ShowEmojiSuggestionList( + emojiSuggestions, + onSelect = { + emojiTarget.value?.replaceCurrentWord(":${it.code}:") + emojiSuggestions.reset() + }, + onFullSize = { + emojiTarget.value?.replaceCurrentWord(":${it.code}:") + emojiSuggestions.reset() + }, + modifier = Modifier.heightIn(max = 200.dp), + ) } }, confirmButton = { @@ -95,8 +135,16 @@ fun EditNicknameDialog( onClick = { accountViewModel.updateContactCardPetName( user = user, - petName = nickname.value.trim().ifBlank { null }, - summary = summary.value.trim().ifBlank { null }, + petName = + nickname.text + .toString() + .trim() + .ifBlank { null }, + summary = + summary.text + .toString() + .trim() + .ifBlank { null }, ) onDismiss() }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4b892769e2..c6f381f16e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3546,7 +3546,7 @@ Edit nickname Nickname - Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. Nickname Private note about this user diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index f8f77d36c9..3aa74c34f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -125,6 +126,22 @@ class EmojiPackState( emptyList(), ) + /** + * Resolves every `:shortcode:` in [message] against the account's selected + * emoji packs, returning the NIP-30 `emoji` tags an event needs to carry for + * the codes to render. Unknown codes are simply skipped. + */ + fun findEmojiTags(message: String): List { + val myEmojiSet = myEmojis.value + if (myEmojiSet.isEmpty()) return emptyList() + return CustomEmoji + .findAllEmojiCodes(message) + .distinct() + .mapNotNull { code -> + myEmojiSet.firstOrNull { it.code == code }?.let { EmojiUrlTag(it.code, it.link) } + } + } + suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { val emojiPackEvent = emojiPack.event if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index e69a38f9b0..42b9142a8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -44,4 +45,14 @@ class ContactCardDecryptionCache( suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() + + /** + * The petname plus the card's full decrypted tag list, so renderers can + * resolve the NIP-30 `emoji` mappings stored alongside it. + */ + suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? { + val merged = cachedPrivateCards.mergeTagList(event) + val name = merged.petName() ?: return null + return PetName(name, merged.toImmutableListOfLists()) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index bc6b0839cd..1c15ff9222 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -85,11 +87,14 @@ class ContactCardsState( ?: flowOf(null) } - /** The petname the account gave [target], decrypted from the card's content. */ + /** + * The petname the account gave [target], decrypted from the card's content, + * along with the card's tags so `:shortcode:` custom emojis resolve. + */ @OptIn(ExperimentalCoroutinesApi::class) - fun petNameFlow(target: User): Flow = + fun petNameFlow(target: User): Flow = myCardFlow(target) - .mapLatest { card -> card?.let { decryptionCache.petName(it) } } + .mapLatest { card -> card?.let { decryptionCache.petNameWithEmojis(it) } } .distinctUntilChanged() .flowOn(Dispatchers.IO) @@ -98,14 +103,16 @@ class ContactCardsState( suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } /** - * Builds the new signed card for [target] with the given petname and summary - * (both stored NIP-44 encrypted; `null` clears the field), preserving every - * other tag of an existing card. The caller is responsible for publishing it. + * Builds the new signed card for [target] with the given petname, summary and + * the NIP-30 emoji mappings their shortcodes use (all stored NIP-44 encrypted; + * `null` clears a field), preserving every other tag of an existing card. The + * caller is responsible for publishing it. */ suspend fun updatePetNameAndSummary( target: HexKey, petName: String?, summary: String?, + emojis: List = emptyList(), ): ContactCardEvent { val existing = getCard(target) return if (existing != null) { @@ -113,6 +120,7 @@ class ContactCardsState( earlierVersion = existing, petName = petName, summary = summary, + emojis = emojis, signer = signer, ) } else { @@ -121,6 +129,7 @@ class ContactCardsState( petName = petName, summary = summary, signer = signer, + privateInitializer = { emojis(emojis) }, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt new file mode 100644 index 0000000000..1cabf8e564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.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.amethyst.commons.model.nip85TrustedAssertions + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists + +/** + * The nickname the account gave a user, together with the card's decrypted tag + * list so renderers can resolve any NIP-30 `:shortcode:` custom emojis the + * petname uses (the `emoji` mappings live encrypted next to the petname). + */ +@Immutable +class PetName( + val petName: String, + val tags: ImmutableListOfLists, +) { + // content equality so flow distinctUntilChanged() dedupes re-decryptions of + // the same card (ImmutableListOfLists itself compares by identity) + override fun equals(other: Any?): Boolean = other is PetName && petName == other.petName && tags.lists.contentDeepEquals(other.tags.lists) + + override fun hashCode(): Int = 31 * petName.hashCode() + tags.contentHash() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index dff8e70ba7..14b5f5711f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent @@ -148,15 +149,18 @@ class ContactCardEvent( } /** - * Replaces the petname and summary of an existing card, keeping every other - * public and private tag intact. Both fields always live in the NIP-44 - * encrypted content — any stray public copy is stripped. A `null` value - * removes the field from the card. + * Replaces the petname, summary and their NIP-30 custom emoji mappings on an + * existing card, keeping every other public and private tag intact. All of + * them always live in the NIP-44 encrypted content — any stray public + * petname/summary copy is stripped. A `null` value removes the field; the + * private `emoji` tag set is replaced wholesale since it only exists to + * render the petname/summary shortcodes. */ suspend fun updatePetNameAndSummary( earlierVersion: ContactCardEvent, petName: String? = null, summary: String? = null, + emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), ): ContactCardEvent { @@ -168,9 +172,13 @@ class ContactCardEvent( privateTags .remove(arrayOf(PetNameTag.TAG_NAME)) .remove(arrayOf(SummaryTag.TAG_NAME)) + .remove(arrayOf(EmojiUrlTag.TAG_NAME)) petName?.let { newPrivateTags = newPrivateTags.plus(PetNameTag.assemble(it)) } summary?.let { newPrivateTags = newPrivateTags.plus(SummaryTag.assemble(it)) } + if (emojis.isNotEmpty()) { + newPrivateTags = newPrivateTags.plus(emojis.map { it.toTagArray() }) + } val newPublicTags = earlierVersion.tags diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt index 4617334369..5146223894 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag @@ -118,6 +120,35 @@ class ContactCardPetNameTest { assertNull(cleared.privateSummary()) } + @Test + fun updateReplacesCustomEmojiMappings() = + runTest { + val oldEmoji = EmojiUrlTag("wave", "https://old.example/wave.png") + val newEmoji = EmojiUrlTag("soapbox", "https://new.example/soapbox.png") + + val card = + ContactCardEvent.create( + targetUser = targetUser, + petName = "Bob :wave:", + signer = signer, + privateInitializer = { emojis(listOf(oldEmoji)) }, + ) + assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) + + val updated = + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bob :soapbox:", + emojis = listOf(newEmoji), + signer = signer, + ) + + assertEquals("Bob :soapbox:", updated.privatePetName()) + // the emoji set is replaced wholesale, still encrypted + assertEquals(listOf(newEmoji), updated.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) + assertTrue(updated.tags.none { it.size > 0 && it[0] == EmojiUrlTag.TAG_NAME }) + } + @Test fun updateStripsLegacyPublicCopies() = runTest { From 903f3d9dcd891045e21e83d1a932961f48361773 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:54:38 +0000 Subject: [PATCH 101/206] feat(ledger): catch-all HTTP metering, radio-burst estimator, media play time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gaps the "should we track the non-websocket client?" question exposed, and adds the battery-estimation layer bytes alone can't provide: - Counting moves from per-role wrappers to a single interceptor on the shared base client (OkHttpClientFactory), with role attribution via request tags (role wrappers now only tag, inserted outside the counter). Anything reaching the shared clients untagged lands in a new "other" subsystem instead of escaping the ledger — which the napplet broker did, and which Coil's image traffic did too: setImageLoader called getHttpClient directly, bypassing the metered okHttpClientForImage. The app's largest HTTP consumer was invisible; now it's tagged "image". - Battery-relevant shape of the traffic, not just its size: per-subsystem request counts and active-transfer time, plus a radio-burst estimator (new HTTP activity after >10s of silence ~= one cellular radio ramp+tail). Scattered small requests cost far more energy than one continuous download of the same byte count; bursts/day makes that pattern visible and reportable. - Media playback time (ExoPlayer isPlaying segments via a listener in ExoPlayerBuilder — every player in the app): decoder+screen+streaming at once, turning "video used 800 MB" into "800 MB over 2h" (normal) vs "over 10 min" (a bug). All surfaced in the usage screen, the NIP-17 report, and UsageSummary; 20 ledger tests total (burst gap logic, play-time segments, and the existing suites) all passing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 11 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 26 +++- .../service/okhttp/DualHttpClientManager.kt | 6 +- .../service/okhttp/OkHttpClientFactory.kt | 9 ++ .../playback/playerPool/ExoPlayerBuilder.kt | 1 + .../playerPool/MediaPlayTimeTracker.kt | 54 +++++++ .../ResourceUsageReportAssembler.kt | 2 + .../resourceusage/UsageCountingInterceptor.kt | 138 ++++++++++++++---- .../service/resourceusage/UsageKeys.kt | 28 +++- .../service/resourceusage/UsageSummary.kt | 8 + .../loggedIn/settings/ResourceUsageScreen.kt | 8 + amethyst/src/main/res/values/strings.xml | 4 + .../resourceusage/ResourceUsageLedgerTest.kt | 53 +++++++ 13 files changed, 317 insertions(+), 31 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 5c8e8643a5..04db249238 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -69,6 +69,17 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: - `crypto.verify.count` / `crypto.verify.us` — event signature verifications (LocalCache.justVerify hook), settling "does Schnorr verify cost matter" with data +- `net....reqs` / `.activems` — HTTP request counts and + active-transfer time per subsystem; counting lives on the shared base + client (OkHttpClientFactory) with tag-based role attribution, so untagged + callers land in `other` instead of escaping the ledger +- `net.bursts..` — estimated radio wake-ups from HTTP burst + patterns (new activity after >10s of HTTP silence): the battery-relevant + measure that bytes alone can't capture, since scattered small requests + each pay the radio ramp+tail +- `media.playms` — actual media playback time (ExoPlayer isPlaying + segments): decoder + screen + streaming at once, the denominator for + video bytes Deliberately not tracked (v1): per-screen time (route names leak behavior patterns into a report — needs its own privacy review), per-coroutine or diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c1c3fe6d71..1c5e65cdf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -90,10 +90,12 @@ import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler +import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStore @@ -299,13 +301,28 @@ class AppModules( val resourceUsage = ResourceUsageAccountant(resourceUsageStore, applicationIOScope) - private val httpUsageMeter = - HttpUsageMeter( + // Estimates radio wake-ups from HTTP burst patterns — bytes alone don't + // predict battery; scattered small requests each pay the radio ramp+tail. + private val radioBurstEstimator = + RadioBurstEstimator( accountant = resourceUsage, isMobile = { connManager.isMobileOrFalse.value }, isForeground = { foregroundTracker.isForeground.value }, ) + // Single catch-all counter on the shared non-relay HTTP client: role + // wrappers only relabel via request tags, so no HTTP traffic (including + // direct getHttpClient users like the napplet broker) escapes the ledger. + private val httpUsageInterceptor = + UsageCountingInterceptor( + accountant = resourceUsage, + isMobile = { connManager.isMobileOrFalse.value }, + isForeground = { foregroundTracker.isForeground.value }, + bursts = radioBurstEstimator, + ) + + private val httpUsageMeter = HttpUsageMeter() + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -325,6 +342,7 @@ class AppModules( master && !profileOnly && localBlossomCacheProbe.available.value }, onionCache = onionLocationCache, + usageInterceptor = httpUsageInterceptor, ) // Offers easy methods to know when connections are happening through Tor or not @@ -871,7 +889,9 @@ class AppModules( diskCache = { diskCache }, memoryCache = { memoryCache }, blossomServerResolver = { blossomResolver }, - callFactory = { okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(it)) }, + // Through the role builder (not raw getHttpClient) so Coil's image + // traffic carries the "image" ledger tag. Same Tor decision inside. + callFactory = { roleBasedHttpClientBuilder.okHttpClientForImage(it) }, thumbnailCache = thumbnailDiskCache, backgroundScope = applicationIOScope, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index e012ec2cdd..dec043dd04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import okhttp3.Call +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import java.net.InetSocketAddress @@ -46,8 +47,11 @@ class DualHttpClientManager( // is uniform across image, upload, NIP-05, money, preview, and push roles. // See [OkHttpClientFactory] kdoc. onionCache: OnionLocationCache, + // Resource-usage ledger counter, installed on the shared base client so + // every derived client is accounted. See [OkHttpClientFactory]. + usageInterceptor: Interceptor? = null, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache, usageInterceptor) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 38efd9eb08..7a3b1f2e2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.okhttp.OkHttpClientFactoryForRelays.Co import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.SurgeDns import okhttp3.ConnectionPool import okhttp3.Dispatcher +import okhttp3.Interceptor import okhttp3.OkHttpClient import java.net.InetSocketAddress import java.net.Proxy @@ -61,6 +62,13 @@ class OkHttpClientFactory( */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, private val onionCache: OnionLocationCache, + /** + * Resource-usage ledger counter, installed OUTERMOST on the shared base + * client so every request through any derived client is accounted — + * per-role tagging wrappers only relabel, they never bypass. Null in + * tests / pre-configuration call sites. + */ + private val usageInterceptor: Interceptor? = null, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -103,6 +111,7 @@ class OkHttpClientFactory( .pingInterval(Duration.ofSeconds(HTTP2_PING_INTERVAL_SECS)) .followRedirects(true) .followSslRedirects(true) + .apply { usageInterceptor?.let { addInterceptor(it) } } .addInterceptor(DefaultContentTypeInterceptor(userAgent)) .apply { blossomCacheRedirect?.let { addInterceptor(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 3a46279de2..7c25825785 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -84,6 +84,7 @@ class ExoPlayerBuilder( addListener(AspectRatioCacher(MediaAspectRatioCache)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) + addListener(MediaPlayTimeTracker()) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.kt new file mode 100644 index 0000000000..44acb2d203 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaPlayTimeTracker.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.service.playback.playerPool + +import android.os.SystemClock +import androidx.media3.common.Player +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys + +/** + * Accounts actual media playback time into the resource-usage ledger. + * Playback is one of the most energy-dense things the app does — decoder, + * screen, and streaming all at once — and this turns "video used 800 MB" + * into "800 MB over 2h of playback" (normal) vs "over 10 minutes" (a bug). + * + * Attached once per player in [ExoPlayerBuilder]; a player released + * mid-playback loses at most its final open segment. + */ +class MediaPlayTimeTracker( + private val onPlayed: (elapsedMs: Long) -> Unit = { ms -> + runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.MEDIA_PLAY_MS, ms) } + }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : Player.Listener { + private var playingSinceMs = Long.MIN_VALUE + + override fun onIsPlayingChanged(isPlaying: Boolean) { + val now = nowMs() + if (isPlaying) { + playingSinceMs = now + } else if (playingSinceMs != Long.MIN_VALUE) { + onPlayed(now - playingSinceMs) + playingSinceMs = Long.MIN_VALUE + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 8e4c9254b3..73ba5d434e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -96,6 +96,8 @@ class ResourceUsageReportAssembler { append("| ... while backgrounded on cellular | ${formatConnHours(s.relayConnMsMobileBg)} |\n") append("| Notification wakelock | ${formatDurationMs(s.wakelockMs)} (${s.wakelockCount}x) |\n") append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") + append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n") + append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 296b038e86..37b198d2cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -29,46 +29,110 @@ import okio.ForwardingSource import okio.Source import okio.buffer import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +/** Request tag carrying the ledger subsystem a request belongs to. */ +class UsageRoleTag( + val role: String, +) /** - * Application interceptor that attributes HTTP traffic to a ledger subsystem - * (image/video/uploads/money/nip05/preview/push). Upload size comes from the - * request body's contentLength; download size is counted as the app actually - * consumes the streamed response body, so partially-read streams (video seek, - * cancelled image loads) count only what crossed the wire to the app. + * Estimates cellular-radio wake-ups caused by HTTP traffic: a new burst is + * counted whenever bytes flow after more than [BURST_GAP_MS] of HTTP silence, + * approximating the radio having dropped to idle in between (LTE/5G inactivity + * timers are typically ~10s). Bytes alone don't predict battery — many small + * scattered requests cost far more than one continuous download of the same + * size, because every burst pays the radio's ramp + tail energy. This counter + * is what makes that pattern visible. * - * Network/visibility dims are sampled when bytes flow, not when the request - * is created — a request issued on wifi that finishes on cellular counts as - * cellular, matching what the radio actually did. + * Caveat: bursts are counted from HTTP activity only. While relays are + * connected their traffic keeps the radio awake anyway — that cost is already + * captured by the relay connection-time counters. */ -class UsageCountingInterceptor( - private val role: String, +class RadioBurstEstimator( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, + private val nowMs: () -> Long = { System.currentTimeMillis() }, +) { + @Volatile private var lastActivityMs = Long.MIN_VALUE + + fun onHttpActivity() { + val now = nowMs() + val last = lastActivityMs + lastActivityMs = now + if (last == Long.MIN_VALUE || now - last > BURST_GAP_MS) { + accountant.add(UsageKeys.radioBursts(isMobile(), isForeground()), 1) + } + } + + companion object { + const val BURST_GAP_MS = 10_000L + } +} + +/** + * Application interceptor that accounts every HTTP request into the ledger: + * bytes up/down, request count, and active-transfer time, attributed to the + * subsystem named by the request's [UsageRoleTag] (set by the per-role + * clients from RoleBasedHttpClientBuilder) or to [defaultRole] for anything + * that reaches the shared clients untagged — so no HTTP traffic can escape + * the ledger. + * + * Installed FIRST on the base client (outermost), so the tagging interceptor + * of role-wrapped clients must be inserted BEFORE it (see [HttpUsageMeter]). + * Download bytes are counted as the app actually consumes the streamed body, + * so cancelled loads count only what crossed to the app. Network/visibility + * dims are sampled when bytes flow, matching what the radio actually did. + */ +class UsageCountingInterceptor( + private val accountant: ResourceUsageAccountant, + private val isMobile: () -> Boolean, + private val isForeground: () -> Boolean, + private val bursts: RadioBurstEstimator? = null, + private val defaultRole: String = UsageKeys.ROLE_OTHER, + private val nowMs: () -> Long = { System.currentTimeMillis() }, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() + val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole + + accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1) + bursts?.onHttpActivity() + val requestBytes = request.body?.contentLength()?.coerceAtLeast(0L) ?: 0L if (requestBytes > 0) { accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = false), requestBytes) } + val startedAtMs = nowMs() val response = chain.proceed(request) - val body = response.body return response .newBuilder() .body( - CountingResponseBody(body) { bytes -> - accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) - }, + CountingResponseBody( + delegate = response.body, + onBytes = { bytes -> + accountant.add(UsageKeys.net(role, isMobile(), isForeground(), received = true), bytes) + bursts?.onHttpActivity() + }, + onFinished = { + accountant.add(UsageKeys.netActiveMs(role, isMobile(), isForeground()), nowMs() - startedAtMs) + }, + ), ).build() } private class CountingResponseBody( private val delegate: ResponseBody, private val onBytes: (Long) -> Unit, + onFinished: () -> Unit, ) : ResponseBody() { + private val finished = AtomicBoolean(false) + private val onFinishedOnce = { + if (finished.compareAndSet(false, true)) onFinished() + } + override fun contentType() = delegate.contentType() override fun contentLength() = delegate.contentLength() @@ -80,9 +144,18 @@ class UsageCountingInterceptor( byteCount: Long, ): Long { val read = super.read(sink, byteCount) - if (read > 0) onBytes(read) + if (read > 0) { + onBytes(read) + } else if (read == -1L) { + onFinishedOnce() + } return read } + + override fun close() { + super.close() + onFinishedOnce() + } }.buffer() } @@ -91,16 +164,14 @@ class UsageCountingInterceptor( } /** - * Caches per-(role, base client) wrapped OkHttp clients so each role gets its - * counting interceptor without rebuilding the shared clients. Base clients - * are rebuilt on proxy/network changes (new identity), so the cache is - * cleared when it grows past a small bound. + * Hands out per-subsystem OkHttp clients: the shared base client (which + * carries the single [UsageCountingInterceptor]) wrapped with a tagging + * interceptor inserted at position 0, OUTSIDE the counter, so the tag is + * visible when the counter reads it. Wrapped clients are cached per + * (role, base identity); base clients are rebuilt on proxy/network changes, + * so the cache is cleared when it grows past a small bound. */ -class HttpUsageMeter( - private val accountant: ResourceUsageAccountant, - private val isMobile: () -> Boolean, - private val isForeground: () -> Boolean, -) { +class HttpUsageMeter { private val wrapped = ConcurrentHashMap, OkHttpClient>() fun counted( @@ -111,13 +182,28 @@ class HttpUsageMeter( return wrapped.getOrPut(role to base) { base .newBuilder() - .addInterceptor(UsageCountingInterceptor(role, accountant, isMobile, isForeground)) + .apply { interceptors().add(0, RoleTaggingInterceptor(role)) } .build() } } + private class RoleTaggingInterceptor( + private val role: String, + ) : Interceptor { + private val tag = UsageRoleTag(role) + + override fun intercept(chain: Interceptor.Chain): Response = + chain.proceed( + chain + .request() + .newBuilder() + .tag(UsageRoleTag::class.java, tag) + .build(), + ) + } + companion object { - // roles (7) x live base clients (proxy on/off ~2) with headroom for + // roles (8) x live base clients (proxy on/off ~2) with headroom for // network-change rebuilds before the clear kicks in. private const val MAX_CACHED = 32 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 93918f5d4b..2b0a799e21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -49,7 +49,10 @@ object UsageKeys { const val ROLE_PREVIEW = "preview" const val ROLE_PUSH = "push" - val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH) + /** Catch-all for HTTP requests that reach the shared clients without a role tag. */ + const val ROLE_OTHER = "other" + + val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER) /** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */ fun net( @@ -59,6 +62,26 @@ object UsageKeys { received: Boolean, ): String = "net.$role.${dim(mobile, foreground)}.${if (received) RX else TX}" + /** `net.image.mobile.bg.reqs` — HTTP request count for a subsystem. */ + fun netReqs( + role: String, + mobile: Boolean, + foreground: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.reqs" + + /** `net.image.mobile.bg.activems` — wall time spent actively transferring. */ + fun netActiveMs( + role: String, + mobile: Boolean, + foreground: Boolean, + ): String = "net.$role.${dim(mobile, foreground)}.activems" + + /** `net.bursts.mobile.bg` — estimated radio wake-ups caused by HTTP traffic. */ + fun radioBursts( + mobile: Boolean, + foreground: Boolean, + ): String = "net.bursts.${dim(mobile, foreground)}" + /** `relay.msg.mobile.bg.rx` — approximate relay websocket payload bytes. */ fun relayMsg( mobile: Boolean, @@ -101,6 +124,9 @@ object UsageKeys { const val VERIFY_COUNT = "crypto.verify.count" const val VERIFY_US = "crypto.verify.us" + /** Media (video/audio) playback time — decoder + screen + streaming all at once. */ + const val MEDIA_PLAY_MS = "media.playms" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 94faefeec3..7a1cd0bb6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -46,6 +46,10 @@ data class UsageSummary( val foregroundMs: Long, val verifyCount: Long, val verifyUs: Long, + val httpRequests: Long, + val radioBursts: Long, + val httpActiveMs: Long, + val mediaPlayMs: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -87,6 +91,10 @@ data class UsageSummary( foregroundMs = counters[UsageKeys.APP_FG_MS] ?: 0L, verifyCount = counters[UsageKeys.VERIFY_COUNT] ?: 0L, verifyUs = counters[UsageKeys.VERIFY_US] ?: 0L, + httpRequests = counters.sumMatching("reqs"), + radioBursts = counters.sumMatching("bursts"), + httpActiveMs = counters.sumMatching("activems"), + mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, bytesPerSubsystem = subsystems, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 8d685c1a37..0e416b735d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -274,6 +274,14 @@ private fun ActivitySection(s: UsageSummary) { SettingsDivider() MetricRow(R.string.resource_usage_reconnects, "${s.relayConnects} (${s.relayConnectFails})") SettingsDivider() + MetricRow(R.string.resource_usage_http_requests, s.httpRequests.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_radio_bursts, s.radioBursts.toString()) + SettingsDivider() + MetricRow(R.string.resource_usage_http_active, formatDurationMs(s.httpActiveMs)) + SettingsDivider() + MetricRow(R.string.resource_usage_media_play, formatDurationMs(s.mediaPlayMs)) + SettingsDivider() MetricRow(R.string.resource_usage_verifies, s.verifyCount.toString()) SettingsDivider() MetricRow(R.string.resource_usage_cpu, formatDurationMs(s.cpuMs)) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 240c09b37c..429795778e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3201,6 +3201,10 @@ Notification processing (CPU awake) Background jobs run Relay reconnections (failed) + Web requests + Radio wake-ups for downloads (est.) + Active transfer time + Media playback time Signatures verified Processor time used Time in app diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 677930b284..f99f1dc451 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -285,6 +286,58 @@ class ForegroundTimeIntegratorTest { } } +class RadioBurstEstimatorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun countsBurstsOnlyAfterRadioIdleGaps() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 1L }) + var now = 100_000L + val estimator = + RadioBurstEstimator( + accountant = accountant, + isMobile = { true }, + isForeground = { false }, + nowMs = { now }, + ) + + estimator.onHttpActivity() // first activity = one burst + now += 2_000 + estimator.onHttpActivity() // 2s later: same burst + now += RadioBurstEstimator.BURST_GAP_MS + 1 + estimator.onHttpActivity() // >10s of silence: new burst + now += 500 + estimator.onHttpActivity() // same burst + + val counters = accountant.allDaysIncludingLive()[1L].orEmpty() + assertEquals(2L, counters[UsageKeys.radioBursts(mobile = true, foreground = false)]) + } +} + +class MediaPlayTimeTrackerTest { + @Test + fun accumulatesOnlyWhilePlaying() { + var now = 0L + var played = 0L + val tracker = MediaPlayTimeTracker(onPlayed = { played += it }, nowMs = { now }) + + tracker.onIsPlayingChanged(true) + now = 30_000L + tracker.onIsPlayingChanged(false) + + now = 100_000L // paused time must not count + tracker.onIsPlayingChanged(true) + now = 105_000L + tracker.onIsPlayingChanged(false) + tracker.onIsPlayingChanged(false) // duplicate stop is a no-op + + assertEquals(35_000L, played) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From 5761f37d0f438345ee0458c2c076fedfcc35c441 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:58:10 +0000 Subject: [PATCH 102/206] feat: pause looping videos after 5 automatic plays Feed videos run under REPEAT_MODE_ONE and looped forever. AutoReplayLimiter counts each MEDIA_ITEM_TRANSITION_REASON_REPEAT as one completed play and pauses the player when the 5th play finishes, leaving the video on its first frame with the play button showing. Pressing play (or the feed mutex resuming a video scrolled back into view) grants a fresh 5-play allowance, and pooled players reset the count when they switch to a different media item. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01N9RXiM42yqznwTVjw8tLZv --- .../playback/playerPool/ExoPlayerBuilder.kt | 2 + .../playerPool/repeat/AutoReplayLimiter.kt | 73 +++++++++++++++ .../repeat/AutoReplayLimiterTest.kt | 91 +++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 3a46279de2..b33afce7a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.playerPool.aspectRatio.AspectRatioCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.CurrentPlayPositionCacher import com.vitorpamplona.amethyst.service.playback.playerPool.positions.VideoViewedPositionCache +import com.vitorpamplona.amethyst.service.playback.playerPool.repeat.AutoReplayLimiter import com.vitorpamplona.amethyst.service.playback.playerPool.wake.KeepVideosPlaying @OptIn(UnstableApi::class) @@ -82,6 +83,7 @@ class ExoPlayerBuilder( ) PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) + addListener(AutoReplayLimiter(pause = ::pause)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.kt new file mode 100644 index 0000000000..b16b4b0714 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiter.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.service.playback.playerPool.repeat + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player + +/** + * Caps how many times a video loops on its own under [Player.REPEAT_MODE_ONE]. + * + * Feed videos are configured to repeat forever (keepPlaying → REPEAT_MODE_ONE in + * PlaybackService), which keeps decoders, network and the screen busy long after the + * user stopped watching. This listener lets a video play [maxAutoPlays] full times and + * then pauses it, so continuing requires an explicit press of the play button. + * + * Each loop under REPEAT_MODE_ONE surfaces as an [onMediaItemTransition] with + * [Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT], marking one completed play. The + * transition has already seeked back to the start, so pausing there leaves the video + * on its first frame with the play button showing. Any resume — the user pressing + * play, or the feed mutex auto-playing when the video scrolls back to the center — + * grants a fresh allowance, and switching to a different media item resets it too. + */ +class AutoReplayLimiter( + val maxAutoPlays: Int = DEFAULT_MAX_AUTO_PLAYS, + val pause: () -> Unit, +) : Player.Listener { + private var playsCompleted = 0 + + override fun onMediaItemTransition( + mediaItem: MediaItem?, + reason: Int, + ) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { + playsCompleted++ + if (playsCompleted >= maxAutoPlays) { + pause() + } + } else { + playsCompleted = 0 + } + } + + override fun onPlayWhenReadyChanged( + playWhenReady: Boolean, + reason: Int, + ) { + if (playWhenReady) { + playsCompleted = 0 + } + } + + companion object { + const val DEFAULT_MAX_AUTO_PLAYS = 5 + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt new file mode 100644 index 0000000000..891c0d5140 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/repeat/AutoReplayLimiterTest.kt @@ -0,0 +1,91 @@ +/* + * 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.service.playback.playerPool.repeat + +import androidx.media3.common.Player +import org.junit.Assert.assertEquals +import org.junit.Test + +class AutoReplayLimiterTest { + private var pauseCalls = 0 + private val limiter = AutoReplayLimiter(maxAutoPlays = 5) { pauseCalls++ } + + private fun startPlaying() = limiter.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + + private fun completeOnePlay() = limiter.onMediaItemTransition(null, Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) + + private fun newMediaItem() = limiter.onMediaItemTransition(null, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + + @Test + fun pausesWhenTheFifthPlayCompletes() { + startPlaying() + + // plays 1..4 complete: repeat transitions 1..4 start plays 2..5 + repeat(4) { completeOnePlay() } + assertEquals(0, pauseCalls) + + // play 5 completes: the 5th repeat transition would start play 6 + completeOnePlay() + assertEquals(1, pauseCalls) + } + + @Test + fun pressingPlayGrantsAFreshAllowance() { + startPlaying() + repeat(5) { completeOnePlay() } + assertEquals(1, pauseCalls) + + // user presses play again + startPlaying() + repeat(4) { completeOnePlay() } + assertEquals(1, pauseCalls) + + completeOnePlay() + assertEquals(2, pauseCalls) + } + + @Test + fun switchingMediaItemsResetsTheCount() { + startPlaying() + repeat(4) { completeOnePlay() } + + // the pooled player is handed a different video + newMediaItem() + + repeat(4) { completeOnePlay() } + assertEquals(0, pauseCalls) + + completeOnePlay() + assertEquals(1, pauseCalls) + } + + @Test + fun pausingMidPlayDoesNotResetTheCount() { + startPlaying() + repeat(3) { completeOnePlay() } + + // a pause alone (e.g. scrolled off screen) does not reset; only a resume does + limiter.onPlayWhenReadyChanged(false, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + + repeat(2) { completeOnePlay() } + assertEquals(1, pauseCalls) + } +} From b282ac32afa059a24e9da94eb66f31628ce7686e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 08:47:43 +0000 Subject: [PATCH 103/206] feat: turn Notify chips into bell mute-toggles listing all thread members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's Notify row previously showed only the parent's p tags and its author, each with an x that removed the user for good. It now lists every thread member (authors along the reply chain plus the parent's mentions), and each chip carries a bell instead: tapping it mutes the notification for that user but keeps the chip — faded and with a bell-off icon so the state is obvious — making it one tap to add them back. Muted members are dropped from the outgoing event's p tags (and from a private note's receivers), and drafts round-trip the muted state by re-deriving thread members whose p tag the draft dropped. The NIP-22 comment composer gets the same chip semantics; there the mute is honored for the optional zap-sender tag, while the structural root/reply scope tags stay as NIP-22 requires. Adds the notifications_off glyph to the Material Symbols subset font. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013eK8mGPcuKYXNyhKVJ8Wg7 --- .../ui/note/creators/notify/Notifying.kt | 21 +++-- .../nip22Comments/CommentPostViewModel.kt | 38 ++++++--- .../nip22Comments/GenericCommentPostScreen.kt | 9 +- .../loggedIn/home/ShortNotePostScreen.kt | 6 +- .../loggedIn/home/ShortNotePostViewModel.kt | 79 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 2 + .../font/material_symbols_outlined.ttf | Bin 464004 -> 467128 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + 8 files changed, 114 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt index 64f1d09337..b7c1ca7a31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/notify/Notifying.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -49,7 +50,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf @OptIn(ExperimentalLayoutApi::class) @Composable @@ -58,8 +62,9 @@ fun Notifying( accountViewModel: AccountViewModel, label: String? = null, showWhenEmpty: Boolean = false, + mutedNotifies: ImmutableSet = persistentSetOf(), onAddUser: (() -> Unit)? = null, - onClick: (User) -> Unit, + onToggleNotify: (User) -> Unit, ) { val mentions = baseMentions?.toSet() @@ -83,7 +88,7 @@ fun Notifying( // spacing matches the horizontal spacing between chips. CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { mentions?.forEach { user -> - NotifyUserChip(user, accountViewModel) { onClick(user) } + NotifyUserChip(user, user.pubkeyHex in mutedNotifies, accountViewModel) { onToggleNotify(user) } } if (onAddUser != null) { @@ -97,12 +102,16 @@ fun Notifying( @Composable private fun NotifyUserChip( user: User, + isMuted: Boolean, accountViewModel: AccountViewModel, - onRemove: () -> Unit, + onToggleNotify: () -> Unit, ) { InputChip( selected = false, - onClick = onRemove, + onClick = onToggleNotify, + // The bell-off icon alone is easy to miss at chip size, so a muted member + // also fades as a second cue while staying in the list for easy re-adding. + modifier = if (isMuted) Modifier.alpha(0.4f) else Modifier, label = { UsernameDisplay( user, @@ -119,8 +128,8 @@ private fun NotifyUserChip( }, trailingIcon = { Icon( - symbol = MaterialSymbols.Close, - contentDescription = stringRes(R.string.notify_remove_user), + symbol = if (isMuted) MaterialSymbols.NotificationsOff else MaterialSymbols.Notifications, + contentDescription = stringRes(if (isMuted) R.string.notify_unmute_user else R.string.notify_mute_user), modifier = Modifier.size(InputChipDefaults.IconSize), ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3c6b6d9b79..0183fee6c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent 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.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate @@ -177,6 +178,21 @@ open class CommentPostViewModel : var notifying by mutableStateOf?>(null) + // Members of the notifying list whose bell is off: they keep their chip + // (so they are one tap away from being added back) but are dropped from + // the extra notification p tags of the outgoing comment. + var mutedNotifies by mutableStateOf>(emptySet()) + + fun toggleNotify(user: User) { + mutedNotifies = + if (user.pubkeyHex in mutedNotifies) { + mutedNotifies - user.pubkeyHex + } else { + mutedNotifies + user.pubkeyHex + } + draftTag.newVersion() + } + // NIP-9B: latest community rules document for the community we're posting into. // Null when the reply target is not a community, or no rules have been observed yet. var communityRules: CommunityRulesEvent? by mutableStateOf(null) @@ -303,6 +319,7 @@ open class CommentPostViewModel : open fun reply(post: Note) { this.replyingTo = post this.externalIdentity = (post.event as? CommentEvent)?.scope() + mutedNotifies = emptySet() (post.event as? LnZapEvent)?.let { zap -> notifying = listOfNotNull(zapSenderToNotify(zap)) } @@ -498,14 +515,16 @@ open class CommentPostViewModel : notifying = draftEvent.rootAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + draftEvent.replyAuthorKeys().mapNotNull { LocalCache.checkGetOrCreateUser(it) } + mutedNotifies = emptySet() // Replies to zaps notify the zap sender through a plain p tag (the receipt's - // author keys above are the lightning provider). Restore the sender chip only - // if the draft still tags them — its absence means the user removed it. + // author keys above are the lightning provider). The sender chip always comes + // back; a missing p tag in the draft means the user muted their bell. (replyingTo?.event as? LnZapEvent)?.let { zap -> zapSenderToNotify(zap)?.let { sender -> - if (draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { - notifying = (notifying ?: emptyList()) + sender + notifying = ((notifying ?: emptyList()) + sender).distinct() + if (!draftEvent.tags.mapNotNull(PTag::parseKey).contains(sender.pubkeyHex)) { + mutedNotifies = mutedNotifies + sender.pubkeyHex } } } @@ -665,9 +684,9 @@ open class CommentPostViewModel : } } else if (replyingToEvent is LnZapEvent) { val sender = zapSenderToNotify(replyingToEvent) - // notifying starts with the sender; a missing entry means the - // user removed the chip, so respect that and don't tag them. - if (sender != null && notifying?.contains(sender) != false) { + // The sender's chip stays in the list; a muted bell means + // the user doesn't want to ping them, so don't tag them. + if (sender != null && sender.pubkeyHex !in mutedNotifies) { listOf(sender.toPTag()) } else { emptyList() @@ -855,6 +874,7 @@ open class CommentPostViewModel : mediaUploadTracker.finishUpload() notifying = null + mutedNotifies = emptySet() wantsInvoice = false wantsZapraiser = false @@ -886,10 +906,6 @@ open class CommentPostViewModel : this.multiOrchestrator?.remove(selected) } - open fun removeFromReplyList(userToRemove: User) { - notifying = notifying?.filter { it != userToRemove } - } - override fun onMessageChanged() { urlPreviews.update(message.text.toString()) revalidateDraft() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 8d4497e18d..9d06d1f355 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -257,8 +258,12 @@ private fun GenericCommentPostBody( } Row { - Notifying(postViewModel.notifying?.toImmutableList(), accountViewModel) { - postViewModel.removeFromReplyList(it) + Notifying( + baseMentions = postViewModel.notifying?.toImmutableList(), + accountViewModel = accountViewModel, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), + ) { + postViewModel.toggleNotify(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 534927c0f1..a9cb01b725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -142,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.launch @@ -327,9 +328,10 @@ private fun NewPostScreenBody( accountViewModel = accountViewModel, label = if (postViewModel.wantsPrivateNote) stringRes(R.string.private_note_visible_to) else null, showWhenEmpty = postViewModel.wantsPrivateNote, + mutedNotifies = postViewModel.mutedNotifies.toImmutableSet(), onAddUser = { postViewModel.wantsToAddNotifyUser = !postViewModel.wantsToAddNotifyUser }, ) { - postViewModel.removeFromReplyList(it) + postViewModel.toggleNotify(it) } } @@ -350,7 +352,7 @@ private fun NewPostScreenBody( ) } - if (postViewModel.wantsPrivateNote && postViewModel.pTags.isNullOrEmpty()) { + if (postViewModel.wantsPrivateNote && postViewModel.activeNotifies().isNullOrEmpty()) { Text( text = stringRes(R.string.private_note_no_receivers), style = MaterialTheme.typography.bodySmall, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 3cea485aef..edc2f40266 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -367,10 +367,29 @@ open class ShortNotePostViewModel : } } + // Members of pTags whose bell is off: they keep their chip in the Notify + // list (so they are one tap away from being added back) but are dropped + // from the outgoing event's p tags. + var mutedNotifies by mutableStateOf>(emptySet()) + + fun toggleNotify(user: User) { + mutedNotifies = + if (user.pubkeyHex in mutedNotifies) { + mutedNotifies - user.pubkeyHex + } else { + mutedNotifies + user.pubkeyHex + } + draftTag.newVersion() + } + + // The users that will actually be p-tagged: the chip list minus the muted ones. + fun activeNotifies(): List? = pTags?.filter { it.pubkeyHex !in mutedNotifies } + fun addToReplyList(user: User) { if (pTags?.contains(user) != true) { pTags = (pTags ?: emptyList()).plus(user) } + mutedNotifies = mutedNotifies - user.pubkeyHex } // A single ephemeral signer reused for the whole compose session so that media @@ -569,6 +588,7 @@ open class ShortNotePostViewModel : originalNote = replyingTo privateNoteLocked = replyingTo?.isPrivateRumor() == true wantsPrivateNote = privateNoteLocked + mutedNotifies = emptySet() replyingTo?.let { replyNote -> if (replyNote.event is BaseThreadedEvent) { this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote) @@ -577,20 +597,7 @@ open class ShortNotePostViewModel : } if (replyNote.event !is CommunityDefinitionEvent) { - replyNote.author?.let { replyUser -> - val currentMentions = - (replyNote.event as? TextNoteEvent) - ?.mentions() - ?.toSet() - ?.map { LocalCache.getOrCreateUser(it.pubKey) } - ?: emptyList() - - if (currentMentions.contains(replyUser)) { - this.pTags = currentMentions - } else { - this.pTags = currentMentions.plus(replyUser) - } - } + this.pTags = threadMembers(replyNote, this.eTags) } } ?: run { @@ -708,6 +715,22 @@ open class ShortNotePostViewModel : } } + // Everyone taking part in the thread gets a Notify chip: whoever the parent + // already p-tags, plus the author of every note in the reply chain. Members + // the user doesn't want to ping are muted via their chip's bell, not removed. + private fun threadMembers( + replyNote: Note, + threadNotes: List?, + ): List { + val mentions = + (replyNote.event as? TextNoteEvent) + ?.mentions() + ?.map { LocalCache.getOrCreateUser(it.pubKey) } + ?: emptyList() + val authors = threadNotes?.mapNotNull { it.author } ?: emptyList() + return (mentions + authors + listOfNotNull(replyNote.author)).distinct() + } + private fun loadFromDraft(draftEvent: TextNoteEvent) { canAddInvoice = accountViewModel.userProfile().lnAddress() != null canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null @@ -786,6 +809,19 @@ open class ShortNotePostViewModel : privateNoteLocked = originalNote?.isPrivateRumor() == true wantsPrivateNote = privateNoteLocked + // A muted thread member is simply absent from the draft's p tags, so + // rebuild the full chip list and mark whoever the draft dropped as muted. + val draftNotifies = pTags.orEmpty().map { it.pubkeyHex }.toSet() + val members = + originalNote + ?.takeIf { it.event !is CommunityDefinitionEvent } + ?.let { threadMembers(it, eTags) } + .orEmpty() + mutedNotifies = members.mapNotNullTo(mutableSetOf()) { member -> member.pubkeyHex.takeIf { it !in draftNotifies } } + if (members.isNotEmpty()) { + pTags = (pTags.orEmpty() + members).distinct() + } + if (forwardZapTo.value.items.isNotEmpty()) { wantsForwardZapTo = true } @@ -845,6 +881,7 @@ open class ShortNotePostViewModel : draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + mutedNotifies = emptySet() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -917,6 +954,7 @@ open class ShortNotePostViewModel : draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.mapNotNull { LocalCache.checkGetOrCreateUser(it[1]) } + mutedNotifies = emptySet() canUsePoll = originalNote == null canUseZapPoll = originalNote == null @@ -1168,9 +1206,11 @@ open class ShortNotePostViewModel : val tags = prepareETagsAsReplyTo(replyingTo, null) accountViewModel.fixReplyTagHints(tags) markedETags(tags) - notify(replyingTo.toPTag()) + if (replyingTo.event.pubKey !in mutedNotifies) { + notify(replyingTo.toPTag()) + } } - pTags?.let { userList -> + activeNotifies()?.let { userList -> val tags = userList.map { val tag = it.toPTag() @@ -1190,7 +1230,7 @@ open class ShortNotePostViewModel : val tagger = NewMessageTagger( message.text.toString().trim(), - pTags, + activeNotifies(), eTags, accountViewModel, ) @@ -1512,6 +1552,7 @@ open class ShortNotePostViewModel : voiceSelectedServer = null voiceOrchestrator = null pTags = null + mutedNotifies = emptySet() wantsPoll = false pollOptions = newStateMapPollOptions() @@ -1562,10 +1603,6 @@ open class ShortNotePostViewModel : this.multiOrchestrator?.remove(selected) } - open fun removeFromReplyList(userToRemove: User) { - pTags = pTags?.filter { it != userToRemove } - } - open fun addToMessage(it: String) { message.setTextAndPlaceCursorAtEnd(message.text.toString() + " " + it) onMessageChanged() diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..4f1f0685b0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1240,6 +1240,8 @@ No receivers yet: only you will be able to see this note. Add people to share it with. Add Remove user from notifications + Notifying. Tap to mute the notification for this user + Muted. Tap to notify this user again Search and add a user to notify is not a bookmark here Remove bookmark from list diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 01bd58472eab53e9922f2ac64092cd463a60370c..07e1c3e472ddbf431f5352f9063062208b4f8b40 100644 GIT binary patch delta 5410 zcmZ`-33L=yy8ds~t)=(w&|5l9r;;?Q3|m%09Fnj_Hp40)t017VjW8NLY7-h86bBun z$f%=up7KN;^${N;qJRq^CoTiZF_1|jO9)9Dvh=>bzq&!0Gw0RGU$^dm>;B8V|NXw| zY};=?blmO&0sscQQP5AGJ-2fACok^?XlVx^yQ=0@%=$~lQ=bC-QUky`?wmbhbj8f2 zz3DSB(IQ_*nb<}m)0+Lth;yNtl9>ElLeTedgF#on>FHFWdNtk0Ze=A?L@KE#X$*_ zu+{8FFu)M_0~7$hP+XXAC@3nn@Vv>8Pl|~!sCZCe!N9y+UKmtVR9sk4lt0>RGMUXL zZVb~ecVJ<@kvEJSIffZIa&NW4P$meW1e7qXCC^W(UQ%frX)HAkx8-GK=2fpuc%3;T z_KrAJO{_WP{mWeX97Cd=fs(jX^4!bSPgE|^>r3@k-kzD4muWZF&VDs>)ch7mcPb1=I*upW(*ckjxB5;<_1VkT z%;&IzNKO5&L$49nh{lnoktasY8ujd`)YFJkNyD4744v}KlrvM3Q}d_(dFp>ntDN@y zbjS2(ru$|T&v;{o@6M@rZo0F%+FZS<`i+_V%(9tBW~pbrI?FeE$LyXttLMBjr)92Y z?)JGC<~irhoVRb@)w@>Qb#T67{?z%$7T6YSU(m2nv2en|Q+Mayz4q?U7jcUwVI|9l znDLLR`+K%#Y5iNDen?oX*!nr22@E+SLDi)*03c^Lg`G%TkNS zQfYCWFt=EyTZRId&sjtZn6I1P!RNIW!Fw~CO|#65d8o17*lyZx zDlzUf==9$j?ll}Xbm>>fP7NG@?vg&J3+wf|6FP)A{o^P^w@l~8ktSV-PSnlP9@ajl zQ)r8H6}ll>^h8y^s#etnzWm5Ztg;n?3z5Evo)~|B#t}Wqs55+JAql)^Y1(d%VcT41 z_CbYpN zvW(mV`Orh=llx&5l#w;CQA=iEKz3**qoIXVA2?CSvNZs8GcLB*OQ#uzG7mE~^|=kN zD4d4%8#dp!>b@1rH{Z8m{idZGR;@Dsho=6}Pd|tz0wkFP9hN--Eeu9L2C}5?wdS$p z>ALgHAB_JJIw1;4LI_JZA`%tR5FN=NMq(ybVk7p{3gi+e8G`FDoQx!+NrAjJq?DAA z@njOIs%vPG7_zp`+cK&yt976PW5&yh7&oQW%hXAyX?@Wp4WkH=8u|3`$LgnDQL-*$ z1X$}1V4a779i|6%zu=i~S z7yAG@+kiaqrz^G!8;1dH zc@SuO1JKTSK)WUZ^_>A4+zK3j3OH2&IMdU>*-rpBa5Hd2cL7(h8@Ms&fvflqxCy1e zP5%nGnKOV}a1U^ca)Dck`ZcKER0&+o8sN4uz&&*axEITSd*vbE_KyVay$CGj4p@Qv z=yTwXIe`0m7;p^-f%|?TaOawUyMVe&Xt>=D+^-l&4;l-10hee3o|^-_;tSw42Z1*{ z3cRfi_?#`k53~b++i2iN+yi|6GWX!l?SnfB3iI*cPWCCSYcTYKha>^kNfaKG0G3Kh z*eEfuK_ZX~W)usG8pVKOg zpWXJ@wrw7vCCBA*)xP+(dq0c)$2=AAx(i-sE0VRY3V0S?cMlvu7(DcZFzH4HfE!OV z-EyfH254B8@`+ejY0>qBV{t)%a4<<&N)xeIJRWBVjm4750bHLy5J<%OxL&U^L)UY) zFA@q~@Amn+JG*`UzCN|pJ=m;=JvmYUyq_FQcx&#TD3ar^M$vvtQb-_#n$$|U?SWqc6H

UA^3@*Ndv&YzKotdds4w{K=k$w0~8tG1=Ya&F{xW6**uu z8WqrXv8yMt6-X`7M;%^D`{}7ylN8Nx^(@s`ty(UWDZ)>~Q+UgXI-bmihV)Q(SKX;v`gVoA&qQP$O zr@Y*8y{oOEtt-fiI!SHpPB~0@`Mpk3UdgRHrfoiRrl}2At);C;GHHizHHao#Fwk`k zEEebR;oZcq*0}Q29U+4)6zEWMy4IhXubMhKI(*S=n@N?c>X#Afh@EY26!)`1F&Oj+ zgyJrQB1tS@AsiMwf=VUOkf>0hLTZ&HkC2Q7gF%JJprSQ9yVWD~_68$j$lr^KkQfSi zXfT>gCTk&r3hcYOJwhPR+Xcx;05wf{O-&ww(`II7UhRyb!r9cM^$5d;Q_~dWt=cH>JN*QiN_PQS{z9x6F4mi2?j_`I3aL& z9)*OUj0Q!GfU_y55P5})=OVs9z^C~4T2Jp#%Ax!ymj>e^9M-y1A<x#-dsMl!2r! zNGT-{jZ&85`FJ=S4yqU~0#08t77j6x^hJ9GJ;4eDj2D8za46Ul#BHG(Q5nmTFumWZ z{kP<>I8mKWPKU|e$jx<2a*0F&V@W3`$;tgkZoE>ZQr^fd4H{?_U8Jv@^)G`4LwH%A zt5Pb(NHmgTQTvh^evX4kgyVR`t6(tT*YaUkIF`)qNk#*hR!`)*!btc)k}}};<9L@p z5XPhw8hupCzTxI~cW6GX!ygHRsN6=P(rBCd;xuv%dV71rkYUoO=r-b~+1hv_9`EzT zwLuc;fea}gxPCnl=SeK40EAD!7J>Jr?;XVIx_N$ZR|w%0YK4$U*7CSHp=jLi&WJ}N z_{qm(7D2zC5*FJQ)*J3O>r?rm-Xxj>bdrXq50orBJF8``N6kSy>(-=I`w6 zH0tF8ay2uv7fGR|z1!G%r3DqeI#(}h67g6p#xn9L)AsdYafd?T2p5ZlP%)rszyLJj zLnEe?k>;|$4sww`9g8GQ!l1Cr?oZBI9{1+f*48g2H&VeQKffpQzw(}+0%T2M6v_OGg}N^ zKdG8hZHN=RDi@FGXsc5qt_d9JJmlUJzPF7ifMXC z@xq0MhK36%ZjdCST5Xiv&Spe@g#N#hH1Nxqf9+ZTl$I3mlbGM1T9;;Z3j8}d($J5f z@9L*A<>uy^QqX_)Y-4kC<5{=Gd;>7$V=kXH878s#i8%$5XibL58`vmAqdA3)f{?<+ z8-OU|p;Li4hi9oue*@-ZeB&9AAub#Y0>imDxxAdjmx8)vG7WSog!A|G^!O3w;z^B0 zDT+#s+iXfBT{^5Zz{z2mWO%z7RT|Xfs7!KHH-kzeSt=-mGz2Mbem}%0R|=#Mp9B_U zCJXreNrvac;TQ(hBjZ#cnM}h}3ZZ(ty1IH1rQ%7wUZX&uazCjeW4&n*LiE|a#h^`r zPduK69vO2yYz#r?XW>F{#_wl=7zR~u7NcG8{ delta 2282 zcmYLJ2~d-WC*s=NP| zcGbEADl-TG7d|n_4C$G%^=zjXaJLR1wMkieQ_c431fW|5Q02^Yzd(K3ycnRZpGW1V zXXrCFKCRde^y#>MzM!&TDMmj066k-xbz@;^LHP=On;iICJRmu@ko#+%l{WMJZ@GVH zQNc2EIbX;)VKtWyi%M25+&(AoPn?_+p88-B4_Gg()&Tc)koct(FDNJqZ4Y2TYXjdO zQp^qZefDm=ptD>bTU=UEm2)t27x!NQ>>igaDJ}11|*L2>dbdRghzlGH6mzWKed{fuOI027-Rox@vv3aoYLX zTJ1^gy#R zZef$clENCoz6qCx8^f1}Z;l9wFxN+PMLds;kNhCAA@X6AEJ_=d9aR$b-Bi`okEV7+ zr$pCAw?_XQql{S`vsX{`{`xKYR{g8kfY`;cHL(npf;1s2 z;nRfg68#dFCN?HMH3S(7466)vhR+SB4PAyG4Z}&}lERWQk_wVmB;A}ge%cQ6v^UB6 z#Fj-Z9aE?Jx2jCFMb$LMOSMMDZ@lUahnuQc6{GT2 zMXAEfDt?JP`A#|Lu|R21DwQ6}qwcSj;mSOvw^DH5=U%MnRD`?# z0Z_Cmio{0og)Ks{UTjq8#BF?)X4A!MVwZSFtQON1TNP%7ox&ns5IwkkSd3GoD5B-J z6Q_$lfc%Cik-w8ak~i^ni73cViuUsN<=5q}<&)i3x(&LSOV@JG57Gxx z2dUBdjPq)#y>o~(S~|g*Gcl*1oQf>!3&$A#+JSg7Ac7kzxcOk}O0yNkP*HsPF^v#~qSt^}QHOX(eF!qHoA*{PrEOnl9~f zw90RO>tN}==SF5*Ufzo!n=FCdN8%phHGai^2qBb6h$C?#qlk=*Cay$I6h!%33!cQA zOyG_9k}1TW1lpP-IucIwB%UOaWXsqdPfKD?fMsRRSVvCFhl2l}_V*0gS?l{p**QKX zm9~c?X|Wy}7(vbMk3qfSLC0+X^?45JJ0EoNAgEsjXuwm@uoTef4$zomp!z+av715T zszBrCf+i$^CQbx3$Uu{L{EStgsdb>ny`X8|gQjzP2H%sp1~fYvbk<4G+1{XYn?Un9 zSCJNGy08JXm=l($K$mdm(k#$QPQHp`wGwnqCFojCww@>6@CoQfZr@}Ct$7Jr%L!}W zfo|yq{nP}ytp&7R4Z4Hp+}Q@YYXJ1mfuQ?&+`&Z9Bb}huD$wIR{_AGY&hci@%RI@I zVbFhZ|lfS-6lkGSpeRnT9!e7gb6u@lVM8EkYdm^c;8eE>||0Oq9x z^U;D$GJ*wf1Jj)Wi{*P0Zh=jo4VKyrX5z>=36_@&Ha8yZeRCyP*$A+uU0}-_!B+Hw zt;zu7owGH^!Pb_6ZJq+QwF2z3yvQ^8m2ka|e)HxH_MK##vd@%F%{a|+i*1Z|5Zxz^}1Z;>`_~tDHG7-Xv zHV7`m5ImY7XwE@+uM>iwGlWn(2w`OqVqZZ>`~*U3J%qF@2)W!ohue!QA(ZZhurdUK zSps2mIfS||AT$<1*gFNnUsE6)_!-&42@`}f`4HL%A)Ma|p-Tqg3b%dF6W=jH=;ehB z@MKTCAw1{4;c5u)UP59w2#KT_5}6Vb`Ef`*jF5OPgk)?zBtD<5tsB}{H$*t>PSp+3 UP0kzd3yeeHkes?N*k4or7afLy{r~^~ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index a038c5603d..adee791893 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -164,6 +164,7 @@ object MaterialSymbols { val NoAccounts = MaterialSymbol("\uF03E") val NoEncryption = MaterialSymbol("\uF03F") val Notifications = MaterialSymbol("\uE7F5") + val NotificationsOff = MaterialSymbol("\uE7F6") val Numbers = MaterialSymbol("\uEAC7") val OpenInBrowser = MaterialSymbol("\uE89D") val OpenInFull = MaterialSymbol("\uF1CE") From 184f0bfde7a3948d71d92d3a4e515f4bf771b529 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:28:23 +0000 Subject: [PATCH 104/206] refactor: align ContactCardEvent with the nip88Polls class structure Replicates the PollEvent layout: all tag parsing moves to TagArray extensions in TagArrayExt.kt with the event accessors delegating to them, builder extensions use addUnique for single-instance tags, and construction goes through template-returning builders instead of methods that sign internally. - build() returns an EventTemplate (the signer is only used to NIP-44 encrypt the private tags, matching TrustProviderListEvent); create() remains as the signer.sign(build(...)) convenience and now takes the emoji list directly - updatePetNameAndSummary() returns an unsigned EventTemplate; callers sign it (ContactCardsState and tests updated) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ContactCardsState.kt | 17 +-- .../users/ContactCardEvent.kt | 117 +++++++++--------- .../users/TagArrayBuilderExt.kt | 36 +++--- .../users/TagArrayExt.kt | 51 ++++++++ .../ContactCardPetNameTest.kt | 49 ++++---- 5 files changed, 167 insertions(+), 103 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 1c15ff9222..1a651c27eb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -116,20 +115,22 @@ class ContactCardsState( ): ContactCardEvent { val existing = getCard(target) return if (existing != null) { - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = existing, - petName = petName, - summary = summary, - emojis = emojis, - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = existing, + petName = petName, + summary = summary, + emojis = emojis, + signer = signer, + ), ) } else { ContactCardEvent.create( targetUser = target, petName = petName, summary = summary, + emojis = emojis, signer = signer, - privateInitializer = { emojis(emojis) }, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index 14b5f5711f..6f276a3716 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -25,34 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.tagArray +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -71,43 +57,43 @@ class ContactCardEvent( fun aboutUser() = tags.dTag() - fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) + fun rank() = tags.rank() - fun followerCount() = tags.firstNotNullOfOrNull(FollowerCountTag::parse) + fun followerCount() = tags.followerCount() - fun firstCreatedAt() = tags.firstNotNullOfOrNull(FirstCreatedAtTag::parse) + fun firstCreatedAt() = tags.firstCreatedAt() - fun postCount() = tags.firstNotNullOfOrNull(PostCountTag::parse) + fun postCount() = tags.postCount() - fun replyCount() = tags.firstNotNullOfOrNull(ReplyCountTag::parse) + fun replyCount() = tags.replyCount() - fun reactionsCount() = tags.firstNotNullOfOrNull(ReactionsCountTag::parse) + fun reactionsCount() = tags.reactionsCount() - fun zapAmountReceived() = tags.firstNotNullOfOrNull(ZapAmountReceivedTag::parse) + fun zapAmountReceived() = tags.zapAmountReceived() - fun zapAmountSent() = tags.firstNotNullOfOrNull(ZapAmountSentTag::parse) + fun zapAmountSent() = tags.zapAmountSent() - fun zapCountReceived() = tags.firstNotNullOfOrNull(ZapCountReceivedTag::parse) + fun zapCountReceived() = tags.zapCountReceived() - fun zapCountSent() = tags.firstNotNullOfOrNull(ZapCountSentTag::parse) + fun zapCountSent() = tags.zapCountSent() - fun zapAvgAmountDayReceived() = tags.firstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse) + fun zapAvgAmountDayReceived() = tags.zapAvgAmountDayReceived() - fun zapAvgAmountDaySent() = tags.firstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse) + fun zapAvgAmountDaySent() = tags.zapAvgAmountDaySent() - fun reportsCountReceived() = tags.firstNotNullOfOrNull(ReportsCountReceivedTag::parse) + fun reportsCountReceived() = tags.reportsCountReceived() - fun reportsCountSent() = tags.firstNotNullOfOrNull(ReportsCountSentTag::parse) + fun reportsCountSent() = tags.reportsCountSent() - fun topics() = tags.mapNotNull(TopicTag::parse) + fun topics() = tags.topics() - fun activeHoursStart() = tags.firstNotNullOfOrNull(ActiveHoursStartTag::parse) + fun activeHoursStart() = tags.activeHoursStart() - fun activeHoursEnd() = tags.firstNotNullOfOrNull(ActiveHoursEndTag::parse) + fun activeHoursEnd() = tags.activeHoursEnd() - fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse) + fun petName() = tags.petName() - fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) + fun summary() = tags.summary() companion object { const val KIND = 30382 @@ -126,35 +112,54 @@ class ContactCardEvent( targetUser: HexKey, petName: String? = null, summary: String? = null, + emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), publicInitializer: TagArrayBuilder.() -> Unit = {}, privateInitializer: TagArrayBuilder.() -> Unit = {}, - ): ContactCardEvent { - val publicTags = - tagArray { - dTag(targetUser) - publicInitializer() - } + ): ContactCardEvent = signer.sign(build(targetUser, petName, summary, emojis, signer, createdAt, publicInitializer, privateInitializer)) + /** + * Unsigned template for a new card about [targetUser]. The petname, summary + * and the NIP-30 emoji mappings their shortcodes use always go in the NIP-44 + * encrypted content ([signer] only encrypts here; the caller signs). + */ + suspend fun build( + targetUser: HexKey, + petName: String? = null, + summary: String? = null, + emojis: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + publicInitializer: TagArrayBuilder.() -> Unit = {}, + privateInitializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { val privateTags = tagArray { petName?.let { petName(it) } summary?.let { summary(it) } + emojis(emojis) privateInitializer() } - val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) - return signer.sign(createdAt, KIND, publicTags, encryptedContent) + return eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateTags, signer), + createdAt = createdAt, + ) { + dTag(targetUser) + publicInitializer() + } } /** - * Replaces the petname, summary and their NIP-30 custom emoji mappings on an - * existing card, keeping every other public and private tag intact. All of - * them always live in the NIP-44 encrypted content — any stray public - * petname/summary copy is stripped. A `null` value removes the field; the - * private `emoji` tag set is replaced wholesale since it only exists to - * render the petname/summary shortcodes. + * Unsigned template that replaces the petname, summary and their NIP-30 + * custom emoji mappings on an existing card, keeping every other public and + * private tag intact. All of them always live in the NIP-44 encrypted + * content — any stray public petname/summary copy is stripped. A `null` + * value removes the field; the private `emoji` tag set is replaced + * wholesale since it only exists to render the petname/summary shortcodes. + * [signer] only decrypts/encrypts here; the caller signs the template. */ suspend fun updatePetNameAndSummary( earlierVersion: ContactCardEvent, @@ -163,7 +168,7 @@ class ContactCardEvent( emojis: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): ContactCardEvent { + ): EventTemplate { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() @@ -185,11 +190,11 @@ class ContactCardEvent( .remove(arrayOf(PetNameTag.TAG_NAME)) .remove(arrayOf(SummaryTag.TAG_NAME)) - return signer.sign( - createdAt, - KIND, - newPublicTags, - PrivateTagsInContent.encryptNip44(newPrivateTags, signer), + return EventTemplate( + createdAt = createdAt, + kind = KIND, + tags = newPublicTags, + content = PrivateTagsInContent.encryptNip44(newPrivateTags, signer), ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt index 5c050c482d..88d87d7359 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayBuilderExt.kt @@ -41,40 +41,40 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDa import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag -fun TagArrayBuilder.rank(rank: Int) = add(RankTag.assemble(rank)) +fun TagArrayBuilder.rank(rank: Int) = addUnique(RankTag.assemble(rank)) -fun TagArrayBuilder.followers(count: Int) = add(FollowerCountTag.assemble(count)) +fun TagArrayBuilder.followers(count: Int) = addUnique(FollowerCountTag.assemble(count)) -fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = add(FirstCreatedAtTag.assemble(timestamp)) +fun TagArrayBuilder.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp)) -fun TagArrayBuilder.postCount(count: Int) = add(PostCountTag.assemble(count)) +fun TagArrayBuilder.postCount(count: Int) = addUnique(PostCountTag.assemble(count)) -fun TagArrayBuilder.replyCount(count: Int) = add(ReplyCountTag.assemble(count)) +fun TagArrayBuilder.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count)) -fun TagArrayBuilder.reactionsCount(count: Int) = add(ReactionsCountTag.assemble(count)) +fun TagArrayBuilder.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count)) -fun TagArrayBuilder.zapAmountReceived(sats: Long) = add(ZapAmountReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAmountSent(sats: Long) = add(ZapAmountSentTag.assemble(sats)) +fun TagArrayBuilder.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats)) -fun TagArrayBuilder.zapCountReceived(count: Int) = add(ZapCountReceivedTag.assemble(count)) +fun TagArrayBuilder.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count)) -fun TagArrayBuilder.zapCountSent(count: Int) = add(ZapCountSentTag.assemble(count)) +fun TagArrayBuilder.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count)) -fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = add(ZapAvgAmountDayReceivedTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats)) -fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = add(ZapAvgAmountDaySentTag.assemble(sats)) +fun TagArrayBuilder.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats)) -fun TagArrayBuilder.reportsCountReceived(count: Int) = add(ReportsCountReceivedTag.assemble(count)) +fun TagArrayBuilder.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count)) -fun TagArrayBuilder.reportsCountSent(count: Int) = add(ReportsCountSentTag.assemble(count)) +fun TagArrayBuilder.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count)) fun TagArrayBuilder.topic(topic: String) = add(TopicTag.assemble(topic)) -fun TagArrayBuilder.activeHoursStart(hour: Int) = add(ActiveHoursStartTag.assemble(hour)) +fun TagArrayBuilder.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour)) -fun TagArrayBuilder.activeHoursEnd(hour: Int) = add(ActiveHoursEndTag.assemble(hour)) +fun TagArrayBuilder.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour)) -fun TagArrayBuilder.petName(name: String) = add(PetNameTag.assemble(name)) +fun TagArrayBuilder.petName(name: String) = addUnique(PetNameTag.assemble(name)) -fun TagArrayBuilder.summary(summary: String) = add(SummaryTag.assemble(summary)) +fun TagArrayBuilder.summary(summary: String) = addUnique(SummaryTag.assemble(summary)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt index 2af2ee5722..fad3a43cc8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/TagArrayExt.kt @@ -22,8 +22,59 @@ package com.vitorpamplona.quartz.nip85TrustedAssertions.users import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag + +fun TagArray.rank() = fastFirstNotNullOfOrNull(RankTag::parse) + +fun TagArray.followerCount() = fastFirstNotNullOfOrNull(FollowerCountTag::parse) + +fun TagArray.firstCreatedAt() = fastFirstNotNullOfOrNull(FirstCreatedAtTag::parse) + +fun TagArray.postCount() = fastFirstNotNullOfOrNull(PostCountTag::parse) + +fun TagArray.replyCount() = fastFirstNotNullOfOrNull(ReplyCountTag::parse) + +fun TagArray.reactionsCount() = fastFirstNotNullOfOrNull(ReactionsCountTag::parse) + +fun TagArray.zapAmountReceived() = fastFirstNotNullOfOrNull(ZapAmountReceivedTag::parse) + +fun TagArray.zapAmountSent() = fastFirstNotNullOfOrNull(ZapAmountSentTag::parse) + +fun TagArray.zapCountReceived() = fastFirstNotNullOfOrNull(ZapCountReceivedTag::parse) + +fun TagArray.zapCountSent() = fastFirstNotNullOfOrNull(ZapCountSentTag::parse) + +fun TagArray.zapAvgAmountDayReceived() = fastFirstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse) + +fun TagArray.zapAvgAmountDaySent() = fastFirstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse) + +fun TagArray.reportsCountReceived() = fastFirstNotNullOfOrNull(ReportsCountReceivedTag::parse) + +fun TagArray.reportsCountSent() = fastFirstNotNullOfOrNull(ReportsCountSentTag::parse) + +fun TagArray.topics() = mapNotNull(TopicTag::parse) + +fun TagArray.activeHoursStart() = fastFirstNotNullOfOrNull(ActiveHoursStartTag::parse) + +fun TagArray.activeHoursEnd() = fastFirstNotNullOfOrNull(ActiveHoursEndTag::parse) fun TagArray.petName() = fastFirstNotNullOfOrNull(PetNameTag::parse) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt index 5146223894..17d1f2d14b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.experimental.nip85TrustedAssertions import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag @@ -77,11 +76,13 @@ class ContactCardPetNameTest { ) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "Bobby", - summary = "new summary", - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bobby", + summary = "new summary", + signer = signer, + ), ) assertEquals(targetUser, updated.aboutUser()) @@ -109,11 +110,13 @@ class ContactCardPetNameTest { ) val cleared = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = null, - summary = null, - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = null, + summary = null, + signer = signer, + ), ) assertNull(cleared.privatePetName()) @@ -130,17 +133,19 @@ class ContactCardPetNameTest { ContactCardEvent.create( targetUser = targetUser, petName = "Bob :wave:", + emojis = listOf(oldEmoji), signer = signer, - privateInitializer = { emojis(listOf(oldEmoji)) }, ) assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse)) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "Bob :soapbox:", - emojis = listOf(newEmoji), - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "Bob :soapbox:", + emojis = listOf(newEmoji), + signer = signer, + ), ) assertEquals("Bob :soapbox:", updated.privatePetName()) @@ -165,10 +170,12 @@ class ContactCardPetNameTest { assertEquals("public bob", card.petName()) val updated = - ContactCardEvent.updatePetNameAndSummary( - earlierVersion = card, - petName = "private bob", - signer = signer, + signer.sign( + ContactCardEvent.updatePetNameAndSummary( + earlierVersion = card, + petName = "private bob", + signer = signer, + ), ) assertNull(updated.petName()) From 2fefc3a409dcd3a7d14faca73953e4baed87546a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:28:59 +0000 Subject: [PATCH 105/206] feat(ui): unify note-header markers into a two-tier pill/quiet-mark system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 1 (HeaderPill): tappable or verifiable metadata now renders as the squared secondary-container chip — location, PoW, OTS (already pills), plus expiration and the bounty reward (bolt icon + amount, tap opens the pledge dialog; icon turns orange once the user has pledged). Tier 2 (QuietMark, new): passive state markers — Boosted, Draft, Edited/ Original, pinned, private rumor — share one spec: 12sp medium gray text with an optional 12dp icon. Draft is no longer a hardcoded English literal (new R.string.draft). Cross-cutting cleanups: - Header rows (NoteCompose First/SecondUserInfoRow, ThreadFeedView) own spacing via Arrangement.spacedBy(5dp) instead of per-marker paddings. - Hashtag/community/fork links use the existing lessImportantLink theme color instead of ad-hoc primary.copy(alpha = 0.52f) (fork was full primary, louder than everything else on the line). - NIP-05 badge drops hardcoded Color.Yellow/Color.Red for theme-aware placeholderText/error. - Note-header timestamps switch to the Short style at 12sp; other TimeAgo callers keep the dotted default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../ui/note/NIP05VerificationDisplay.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 78 ++++++------------ .../amethyst/ui/note/elements/BoostedMark.kt | 15 +--- .../ui/note/elements/DisplayCommunity.kt | 6 +- .../ui/note/elements/DisplayEditStatus.kt | 40 +++------- .../ui/note/elements/DisplayHashtags.kt | 3 +- .../amethyst/ui/note/elements/DisplayOts.kt | 3 - .../ui/note/elements/DisplayReward.kt | 45 +++-------- .../amethyst/ui/note/elements/ForkInfo.kt | 3 +- .../amethyst/ui/note/elements/HeaderPill.kt | 4 +- .../amethyst/ui/note/elements/QuietMark.kt | 80 +++++++++++++++++++ .../amethyst/ui/note/elements/TimeAgo.kt | 17 +++- .../loggedIn/threadview/ThreadFeedView.kt | 16 +++- amethyst/src/main/res/values/strings.xml | 2 + 14 files changed, 167 insertions(+), 149 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt index 6bcb102918..b87ed48c44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NIP05VerificationDisplay.kt @@ -472,7 +472,7 @@ fun RenderNIP05VerifiedSymbol( symbol = MaterialSymbols.Downloading, contentDescription = stringRes(id = R.string.nip05_checking), modifier = modifier, - tint = Color.Yellow, + tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -492,7 +492,7 @@ fun RenderNIP05VerifiedSymbol( symbol = MaterialSymbols.Report, contentDescription = stringRes(id = R.string.nip05_failed), modifier = modifier, - tint = Color.Red, + tint = MaterialTheme.colorScheme.error, ) } } 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 6286a1a484..39ef85a00c 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 @@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background 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.PaddingValues @@ -32,7 +33,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -53,7 +53,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -85,11 +84,14 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward +import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.QuietMark import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -207,8 +209,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.Size25dp @@ -216,7 +216,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size30dp import com.vitorpamplona.amethyst.ui.theme.Size34dp import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier @@ -1743,6 +1743,7 @@ fun SecondUserInfoRow( Row( verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), modifier = UserNameMaxRowHeight, ) { Column(modifier = Modifier.weight(1f)) { @@ -1755,32 +1756,19 @@ fun SecondUserInfoRow( val baseReward = remember(noteEvent) { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { - Spacer(StdHorzSpacer) - DisplayReward(baseReward, note, accountViewModel, nav) + DisplayReward(baseReward, note, accountViewModel) } } } @Composable fun DisplayExpiration(expirationDate: Long) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - symbol = MaterialSymbols.Timer, - contentDescription = stringRes(R.string.expiration_date_label), - modifier = Modifier.padding(start = 5.dp).size(12.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - val context = LocalContext.current - Text( - text = timeAheadNoDot(expirationDate, context), - color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - maxLines = 1, - modifier = Modifier.padding(start = 3.dp), - ) - } + val context = LocalContext.current + HeaderPill( + symbol = MaterialSymbols.Timer, + text = timeAheadNoDot(expirationDate, context), + contentDescription = stringRes(R.string.expiration_date_label), + ) } @Composable @@ -1800,26 +1788,12 @@ fun DisplayOtsIfInOriginal( @Composable fun DisplayDraft() { - Text( - "Draft", - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) + QuietMark(text = stringRes(R.string.draft)) } @Composable fun DisplayDraftChat() { - Text( - "Draft", - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier, - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - maxLines = 1, - ) + QuietMark(text = stringRes(R.string.draft)) } @Composable @@ -1832,7 +1806,11 @@ fun FirstUserInfoRow( nav: INav, moreOptions: (@Composable () -> Unit)? = null, ) { - Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) { + Row( + verticalAlignment = CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + modifier = UserNameRowHeight, + ) { val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent val isDraft = baseNote.isDraft() val textColor = if (isRepost) MaterialTheme.colorScheme.grayText else Color.Unspecified @@ -1849,12 +1827,10 @@ fun FirstUserInfoRow( if (zapSender != null) { if (showAuthorPicture) { UserPicture(zapSender, Size25dp, accountViewModel = accountViewModel, nav = nav) - Spacer(HalfPadding) } UsernameDisplay(zapSender, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } else if (showAuthorPicture) { NoteAuthorPicture(baseNote, Size25dp, accountViewModel = accountViewModel, nav = nav) - Spacer(HalfPadding) NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } else { NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) @@ -1908,13 +1884,11 @@ fun FirstUserInfoRow( val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } if (geo != null) { - Spacer(StdHorzSpacer) DisplayLocation(geo, accountViewModel, nav) } val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } if (pow != null) { - Spacer(StdHorzSpacer) DisplayPoW(pow) } @@ -1924,7 +1898,7 @@ fun FirstUserInfoRow( JumpToParentReplyButton(baseNote, accountViewModel, nav) - TimeAgo(baseNote) + TimeAgo(baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) if (moreOptions == null) { MoreOptionsButton(baseNote, editState, accountViewModel, nav) @@ -1936,21 +1910,17 @@ fun FirstUserInfoRow( @Composable fun PinnedMark() { - Icon( + QuietMark( symbol = MaterialSymbols.PushPin, contentDescription = stringRes(R.string.pinned_notes), - modifier = Modifier.padding(start = 5.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, ) } @Composable fun PrivateRumorMark() { - Icon( + QuietMark( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), - modifier = Modifier.padding(start = 5.dp).size(14.dp), - tint = MaterialTheme.colorScheme.placeholderText, ) } @@ -1976,7 +1946,7 @@ fun JumpToParentReplyButton( } ?: return ClickableBox( - modifier = Modifier.padding(start = 5.dp).size(20.dp), + modifier = Modifier.size(20.dp), onClick = { nav.nav { routeFor(parentNote, accountViewModel.account) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index d214b3391c..7b81088adc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -20,24 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun BoostedMark() { - Text( - stringRes(id = R.string.boosted), - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - modifier = HalfStartPadding, - ) + QuietMark(text = stringRes(id = R.string.boosted)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 404688cc03..64b27c68a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -36,7 +36,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.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -47,7 +47,7 @@ fun DisplayFollowingCommunityInPost( accountViewModel: AccountViewModel, nav: INav, ) { - Column(HalfStartPadding) { + Column { Row(verticalAlignment = Alignment.CenterVertically) { DisplayCommunity(baseNote, accountViewModel, nav) } } } @@ -67,7 +67,7 @@ private fun DisplayCommunity( ClickableTextColor( label, style = LocalTextStyle.current.copy(fontSize = Font12SP), - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index ee31e07dc3..48a2e6b60f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -20,40 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.clickable -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding -import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable fun DisplayEditStatus(editState: EditState) { - Text( - text = - buildAnnotatedString { - if (editState.showingVersion.value == editState.originalVersionId()) { - append(stringRes(id = R.string.original)) - } else if (editState.showingVersion.value == editState.lastVersionId()) { - append(stringRes(id = R.string.edited)) - } else { - append(stringRes(id = R.string.edited_number, editState.versionId())) - } - }, - style = - LocalTextStyle.current.copy( - color = MaterialTheme.colorScheme.placeholderText, - fontWeight = FontWeight.Bold, - fontSize = Font12SP, - ), - maxLines = 1, - modifier = HalfStartPadding.clickable { editState.nextModification() }, + val label = + if (editState.showingVersion.value == editState.originalVersionId()) { + stringRes(id = R.string.original) + } else if (editState.showingVersion.value == editState.lastVersionId()) { + stringRes(id = R.string.edited) + } else { + stringRes(id = R.string.edited_number, editState.versionId()) + } + + QuietMark( + text = label, + onClick = { editState.nextModification() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index b9f73c3367..cc48d6d08e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -40,6 +40,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.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId @@ -85,7 +86,7 @@ private fun DisplayTagList( ClickableTextColor( "#$firstTag", style = LocalTextStyle.current.copy(fontSize = Font12SP), - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index fdf5f6890c..c5cf453138 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import java.text.SimpleDateFormat import java.util.Date @@ -63,7 +62,6 @@ fun DisplayOts( HeaderPill( symbol = MaterialSymbols.CheckCircle, text = stringRes(R.string.existed_since, timeStr), - modifier = HalfStartPadding, contentDescription = stringRes(R.string.ots_info_title), onClick = { accountViewModel.toastManager.toast( @@ -78,7 +76,6 @@ fun DisplayOts( HeaderPill( symbol = MaterialSymbols.Schedule, text = stringRes(id = R.string.timestamp_pending_short), - modifier = HalfStartPadding, contentDescription = stringRes(R.string.ots_info_title), ) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index b392b6a50c..0b36780bd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -55,20 +54,16 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextColor +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.note.ZapIcon -import com.vitorpamplona.amethyst.ui.note.ZappedIcon import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton import com.vitorpamplona.amethyst.ui.note.buttons.PostButton import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -83,28 +78,13 @@ fun DisplayReward( baseReward: Reward, baseNote: Note, accountViewModel: AccountViewModel, - nav: INav, ) { var popupExpanded by remember { mutableStateOf(false) } - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.clickable { popupExpanded = true }, - ) { - ClickableTextColor( - "#bounty", - linkColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), - ) { - nav.nav(Route.Hashtag("bounty")) - } + RenderPledgeAmount(baseNote, baseReward, accountViewModel) { popupExpanded = true } - RenderPledgeAmount(baseNote, baseReward, accountViewModel) - } - - if (popupExpanded) { - AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } - } + if (popupExpanded) { + AddBountyAmountDialog(baseNote, accountViewModel) { popupExpanded = false } } } @@ -113,6 +93,7 @@ private fun RenderPledgeAmount( baseNote: Note, baseReward: Reward, accountViewModel: AccountViewModel, + onClick: () -> Unit, ) { val repliesState by observeNoteReplies(baseNote, accountViewModel) var reward by remember { @@ -142,16 +123,12 @@ private fun RenderPledgeAmount( } } - if (hasPledge) { - ZappedIcon(modifier = Size20Modifier) - } else { - ZapIcon(modifier = Size20Modifier, MaterialTheme.colorScheme.placeholderText) - } - - Text( + HeaderPill( + symbol = MaterialSymbols.Bolt, text = reward, - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, + contentDescription = stringRes(R.string.bounty_label), + iconTint = if (hasPledge) BitcoinOrange else null, + onClick = onClick, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index e39303df66..7b6486bfd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.experimental.forks.IForkableEvent @Composable @@ -85,7 +86,7 @@ fun ForkInformationRowLightColor( clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()), maxLines = 1, route = route, - overrideColor = MaterialTheme.colorScheme.primary, + overrideColor = MaterialTheme.colorScheme.lessImportantLink, fontSize = Font14SP, nav = nav, tags = userState?.tags, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt index 2e70a1f398..6661391c8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt @@ -32,6 +32,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -49,6 +50,7 @@ fun HeaderPill( text: String, modifier: Modifier = Modifier, contentDescription: String? = null, + iconTint: Color? = null, onClick: (() -> Unit)? = null, ) { Surface( @@ -64,7 +66,7 @@ fun HeaderPill( Icon( symbol = symbol, contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onSecondaryContainer, + tint = iconTint ?: MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.size(11.dp), ) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt new file mode 100644 index 0000000000..acd339471e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.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.amethyst.ui.note.elements + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +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.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, + * private rumor, ... One spec for all of them so the header reads as a single + * system: 12sp medium gray text with an optional 12dp icon. Contrast with + * [HeaderPill], which is the loud tier for tappable/verifiable metadata. + * + * Spacing between markers is owned by the parent row (`Arrangement.spacedBy`), + * not baked in here. + */ +@Composable +fun QuietMark( + text: String? = null, + symbol: MaterialSymbol? = null, + contentDescription: String? = null, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + ) { + if (symbol != null) { + Icon( + symbol = symbol, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(12.dp), + ) + } + if (text != null) { + Text( + text = text, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font12SP, + lineHeight = Font12SP, + fontWeight = FontWeight.Medium, + maxLines = 1, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index c23fd2d41c..af77ed2b82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -125,16 +125,25 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo(note: Note) { +fun TimeAgo( + note: Note, + style: TimeAgoStyle = TimeAgoStyle.Dotted, + fontSize: TextUnit = TextUnit.Unspecified, +) { val time = note.createdAt() ?: return - TimeAgo(time) + TimeAgo(time, style, fontSize) } @Composable -fun TimeAgo(time: Long) { +fun TimeAgo( + time: Long, + style: TimeAgoStyle = TimeAgoStyle.Dotted, + fontSize: TextUnit = TextUnit.Unspecified, +) { ToggleableTimeAgoText( timestamp = time, - style = TimeAgoStyle.Dotted, + style = style, + fontSize = fontSize, color = MaterialTheme.colorScheme.placeholderText, ) } 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 a2ccc498b7..11c461fee7 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 @@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -231,6 +232,7 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -703,7 +705,10 @@ private fun FullBleedNoteCompose( } Column(modifier = Modifier.padding(start = 10.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { if (zapSender != null) { UsernameDisplay(zapSender, Modifier.weight(1f), accountViewModel = accountViewModel) } else { @@ -738,12 +743,15 @@ private fun FullBleedNoteCompose( Expiration(baseNote) - TimeAgo(note = baseNote) + TimeAgo(note = baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) MoreOptionsButton(baseNote, editState, accountViewModel, nav) } - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Size5dp), + ) { Column( Modifier.weight(1f), ) { @@ -765,7 +773,7 @@ private fun FullBleedNoteCompose( val baseReward = remember { noteEvent.bountyBaseReward()?.let { Reward(it) } } if (baseReward != null) { - DisplayReward(baseReward, baseNote, accountViewModel, nav) + DisplayReward(baseReward, baseNote, accountViewModel) } val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 994f57d2a0..a3ef6e6b7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3041,6 +3041,8 @@ It\'s not possible to react a draft note It\'s not possible to zap a draft note Draft Note + Draft + Bounty From Msg From ab4b0b984d711eeec5e42d6b00998cb070dbe170 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:40:38 +0000 Subject: [PATCH 106/206] =?UTF-8?q?refactor:=20audit=20nickname=20code=20?= =?UTF-8?q?=E2=80=94=20dedupe=20emoji=20helpers=20into=20commons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the nickname/contact-card feature for reuse, simplicity and performance: - EmojiSuggestionState moves to commons next to EmojiPackState and now takes the EmojiPackState directly instead of the whole Android Account, so the desktop app can reuse the :shortcode: autocomplete - the findEmoji helper that was copy-pasted in 8 composer ViewModels is gone; everyone calls the shared EmojiPackState.findEmojiTags, which now resolves codes through a map lookup instead of a linear scan per code - ContactCardsState owns the emoji resolution for nickname saves (Account just publishes), takes EmojiPackState, and drops its unused scope param - new synchronous cachedPetName path (decryption-cache read, no crypto) seeds observeUserName/observeUserPetName initial values, removing the flash of the real name before the flow's first emission - nickname dialog prefill no longer clobbers text typed while a slow external signer decrypts the existing card Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../vitorpamplona/amethyst/model/Account.kt | 19 +++++----- .../reqCommand/user/UserObservers.kt | 14 ++++++-- .../nip22Comments/CommentPostViewModel.kt | 23 ++---------- .../privateDM/send/ChatNewMessageViewModel.kt | 18 ++-------- .../send/ChannelNewMessageViewModel.kt | 18 ++-------- .../nip23LongForm/LongFormPostViewModel.kt | 18 ++-------- .../nip99Classifieds/NewProductViewModel.kt | 18 ++-------- .../loggedIn/home/ShortNotePostViewModel.kt | 18 ++-------- .../room/chat/NestNewMessageViewModel.kt | 18 ++-------- .../NewPublicMessageViewModel.kt | 18 ++-------- .../profile/header/EditNicknameDialog.kt | 9 +++-- .../model/nip30CustomEmojis/EmojiPackState.kt | 3 +- .../EmojiSuggestionState.kt | 15 ++++---- .../ContactCardDecryptionCache.kt | 21 ++++++----- .../ContactCardsState.kt | 36 +++++++++++++------ 15 files changed, 99 insertions(+), 167 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis}/EmojiSuggestionState.kt (86%) 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 103170391b..a48e2fab1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -420,9 +420,6 @@ class Account( val trustProviderListDecryptionCache = TrustProviderListDecryptionCache(signer) val trustProviderList = TrustProviderListState(signer, cache, trustProviderListDecryptionCache, scope, settings) - val contactCardDecryptionCache = ContactCardDecryptionCache(signer) - val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, scope) - val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) val peopleLists = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) @@ -440,6 +437,10 @@ class Account( val emoji = EmojiPackState(signer, cache, scope) val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope) + // needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs + val contactCardDecryptionCache = ContactCardDecryptionCache(signer) + val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji) + val vanish = VanishRequestsState(signer, cache, client, scope) val appSpecific = AppSpecificState(signer, cache, scope, settings) @@ -3778,19 +3779,15 @@ class Account( /** * Nicknames a user by publishing the account's kind:30382 contact card about - * them, with the petname and summary NIP-44 encrypted in the content. Any - * `:shortcode:` from the account's emoji packs gets its NIP-30 emoji mapping - * embedded (also encrypted) so the nickname renders with custom emojis. - * `null` clears a field. Goes out through the account's extended outbox relays. + * them, with the petname, summary and their custom emoji mappings NIP-44 + * encrypted in the content. `null` clears a field. Goes out through the + * account's extended outbox relays. */ suspend fun updateContactCardPetName( pubkeyHex: HexKey, petName: String?, summary: String?, - ) { - val emojis = emoji.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) - sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary, emojis)) - } + ) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary)) suspend fun showUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 768fe0996b..beb8cb9182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -71,8 +71,15 @@ fun observeUserName( }.distinctUntilChanged() } + val initialName = + remember(user) { + accountViewModel.account.contactCards + .cachedPetName(user) + ?.petName ?: user.toBestDisplayName() + } + // Subscribe in the LocalCache for changes that arrive in the device - return flow.collectAsStateWithLifecycle(user.toBestDisplayName()) + return flow.collectAsStateWithLifecycle(initialName) } /** @@ -87,9 +94,10 @@ fun observeUserPetName( user: User, accountViewModel: AccountViewModel, ): State { - val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } + val contactCards = accountViewModel.account.contactCards + val flow = remember(user) { contactCards.petNameFlow(user) } - return flow.collectAsStateWithLifecycle(null) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedPetName(user) }) } @OptIn(ExperimentalCoroutinesApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 3c6b6d9b79..b4d9c028ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -96,8 +96,6 @@ import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip29RelayGroups.groupId import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -275,7 +273,7 @@ open class CommentPostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun newPostFor(externalIdentity: ExternalId) { @@ -626,7 +624,7 @@ open class CommentPostViewModel : val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -717,21 +715,6 @@ open class CommentPostViewModel : return template } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { - EmojiUrlTag( - it.code, - it.link, - ) - } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index c5c94e64c6..fc6ed14ce5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -33,6 +33,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst 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.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -80,8 +80,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -276,7 +274,7 @@ class ChatNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState( @@ -580,7 +578,7 @@ class ChatNewMessageViewModel : val messageText = message.text.toString() val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val message = messageText @@ -635,16 +633,6 @@ class ChatNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun cancel() { draftTag.rotate() 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..eab9d48766 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 @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay @@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -88,8 +88,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip28PublicChat.base.notify import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class ChannelNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -433,7 +431,7 @@ open class ChannelNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val channelRelays = channel.relays() val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -597,16 +595,6 @@ open class ChannelNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - open fun cancel() { draftTag.rotate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 7bc0a0f755..cb6d7bdc80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -87,8 +87,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -234,7 +232,7 @@ class LongFormPostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun load( @@ -416,7 +414,7 @@ class LongFormPostViewModel : val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -446,16 +444,6 @@ class LongFormPostViewModel : } } - private fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun uploadCoverImage( uri: SelectedMedia, context: Context, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 0cbfe7a650..f4206076ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R 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.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -79,8 +79,6 @@ 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.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class NewProductViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun editFromDraft(draft: Note) { @@ -351,7 +349,7 @@ open class NewProductViewModel : ) tagger.run() - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } @@ -391,16 +389,6 @@ open class NewProductViewModel : return template } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 3cea485aef..03e6d4c30b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay import com.vitorpamplona.amethyst.commons.ui.text.appendSignature import com.vitorpamplona.amethyst.commons.ui.text.currentWord @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -124,8 +124,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip22Comments.notify import com.vitorpamplona.quartz.nip29RelayGroups.hTag -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -517,7 +515,7 @@ open class ShortNotePostViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } /** @@ -1201,7 +1199,7 @@ open class ShortNotePostViewModel : val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -1381,16 +1379,6 @@ open class ShortNotePostViewModel : replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME) } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index 44bfb87c27..dd164b0792 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.richtext.UrlParser import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor @@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState @@ -75,8 +75,6 @@ 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.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -212,7 +210,7 @@ open class NestNewMessageViewModel : ) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload) } @@ -427,7 +425,7 @@ open class NestNewMessageViewModel : val urls = findURLs(messageText) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) - val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value) + val emojis = accountViewModel.account.emoji.findEmojiTags(messageText) val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null @@ -467,16 +465,6 @@ open class NestNewMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - open fun cancel() { draftTag.rotate() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index a97e2068da..ff9988c888 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R 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.ui.text.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord @@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField @@ -84,8 +84,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds import com.vitorpamplona.quartz.nip19Bech32.toNpub -import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason @@ -222,7 +220,7 @@ class NewPublicMessageViewModel : this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder()) this.emojiSuggestions?.reset() - this.emojiSuggestions = EmojiSuggestionState(accountVM.account) + this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji) } fun load(users: Set) { @@ -393,7 +391,7 @@ class NewPublicMessageViewModel : val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null - val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) + val emojis = account.emoji.findEmojiTags(tagger.message) val urls = findURLs(tagger.message) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) @@ -419,16 +417,6 @@ class NewPublicMessageViewModel : } } - fun findEmoji( - message: String, - myEmojiSet: List?, - ): List { - if (myEmojiSet == null) return emptyList() - return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> - myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) } - } - } - fun upload( alt: String?, contentWarningReason: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt index dd133fa887..54f1a469a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -37,11 +37,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.commons.ui.text.currentWord import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -64,20 +64,23 @@ fun EditNicknameDialog( ) { val nickname = rememberTextFieldState() val summary = rememberTextFieldState() - val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account) } + val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } // which field the emoji autocomplete should insert into val emojiTarget = remember { mutableStateOf(null) } // keeps the account's selected emoji packs loaded while the dialog is open WatchAndLoadMyEmojiList(accountViewModel) - // Prefill with the card's current encrypted values, if any. + // Prefill with the card's current encrypted values, if any. Decryption can + // be slow on external signers, so don't clobber anything already typed. LaunchedEffect(user) { accountViewModel.account.contactCards .petName(user.pubkeyHex) + ?.takeIf { nickname.text.isEmpty() } ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } accountViewModel.account.contactCards .summary(user.pubkeyHex) + ?.takeIf { summary.text.isEmpty() } ?.let { summary.setTextAndPlaceCursorAtEnd(it) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index 3aa74c34f4..decf5f345e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -134,11 +134,12 @@ class EmojiPackState( fun findEmojiTags(message: String): List { val myEmojiSet = myEmojis.value if (myEmojiSet.isEmpty()) return emptyList() + val byCode = myEmojiSet.associateBy { it.code } return CustomEmoji .findAllEmojiCodes(message) .distinct() .mapNotNull { code -> - myEmojiSet.firstOrNull { it.code == code }?.let { EmojiUrlTag(it.code, it.link) } + byCode[code]?.let { EmojiUrlTag(it.code, it.link) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt index 67fc66229b..08dec98379 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/EmojiSuggestionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt @@ -18,25 +18,28 @@ * 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.note.creators.emojiSuggestions +package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis import androidx.compose.runtime.Stable -import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState -import com.vitorpamplona.amethyst.model.Account import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn +/** + * Backs the `:shortcode:` autocomplete in text fields: feed the word under the + * cursor into [processCurrentWord] and collect [results] for the matching + * emojis from the account's selected packs. + */ @Stable class EmojiSuggestionState( - val account: Account, + val emojiPacks: EmojiPackState, ) { val search: MutableStateFlow = MutableStateFlow("") val results: Flow> = - account - .emoji.myEmojis + emojiPacks.myEmojis .combine(search) { list, search -> if (search.length == 1) { list diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index 42b9142a8e..f47c2204c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent @@ -38,10 +39,6 @@ class ContactCardDecryptionCache( ) { val cachedPrivateCards = PrivateTagArrayEventCache(signer, cacheSize = 100) - fun cachedPetName(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).petName() - - fun cachedSummary(event: ContactCardEvent) = cachedPrivateCards.mergeTagListPrecached(event).summary() - suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName() suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() @@ -50,9 +47,17 @@ class ContactCardDecryptionCache( * The petname plus the card's full decrypted tag list, so renderers can * resolve the NIP-30 `emoji` mappings stored alongside it. */ - suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? { - val merged = cachedPrivateCards.mergeTagList(event) - val name = merged.petName() ?: return null - return PetName(name, merged.toImmutableListOfLists()) + suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagList(event).toPetName() + + /** + * Synchronous variant that only reads an already-decrypted card, for use as + * the immediate value of UI flows. Returns null until the suspend path has + * decrypted the card once. + */ + fun cachedPetNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagListPrecached(event).toPetName() + + private fun TagArray.toPetName(): PetName? { + val name = petName() ?: return null + return PetName(name, toImmutableListOfLists()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 1a651c27eb..9ed53fc670 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -24,12 +24,11 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO @@ -55,7 +54,7 @@ class ContactCardsState( val signer: NostrSigner, val cache: ICacheProvider, val decryptionCache: ContactCardDecryptionCache, - val scope: CoroutineScope, + val emojiPacks: EmojiPackState, ) { private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) } @@ -67,8 +66,8 @@ class ContactCardsState( /** * The account's own card about [target], as attached to the target user's - * [UserCardsCache] when the event is consumed. Reads from the existing - * received-cards map so displaying users without a card allocates nothing new. + * [UserCardsCache] when the event is consumed. `cards()` lazily allocates the + * per-user cache (like `metadata()` does) so a card arriving later is seen. */ @OptIn(ExperimentalCoroutinesApi::class) fun myCardFlow(target: User): Flow = @@ -97,22 +96,39 @@ class ContactCardsState( .distinctUntilChanged() .flowOn(Dispatchers.IO) + /** + * Synchronously returns the petname for [target] when its card is already + * decrypted (or has none to decrypt). Cheap enough for initial values of UI + * flows: a map read plus the decryption cache lookup, no crypto. + */ + fun cachedPetName(target: User): PetName? { + val card = + target + .cardsOrNull() + ?.receivedCards + ?.value + ?.get(accountUser) + ?.event as? ContactCardEvent ?: return null + return decryptionCache.cachedPetNameWithEmojis(card) + } + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } /** - * Builds the new signed card for [target] with the given petname, summary and - * the NIP-30 emoji mappings their shortcodes use (all stored NIP-44 encrypted; - * `null` clears a field), preserving every other tag of an existing card. The - * caller is responsible for publishing it. + * Builds the new signed card for [target] with the given petname and summary + * (`null` clears a field), preserving every other tag of an existing card. + * Any `:shortcode:` from the account's emoji packs gets its NIP-30 emoji + * mapping embedded; everything is stored NIP-44 encrypted. The caller is + * responsible for publishing it. */ suspend fun updatePetNameAndSummary( target: HexKey, petName: String?, summary: String?, - emojis: List = emptyList(), ): ContactCardEvent { + val emojis = emojiPacks.findEmojiTags(listOfNotNull(petName, summary).joinToString(" ")) val existing = getCard(target) return if (existing != null) { signer.sign( From 648e9f427a59177c64cf3c51e42f5e598bdf5acb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 12:58:36 +0000 Subject: [PATCH 107/206] 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 108/206] 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 b69304c4a88a29848ac606e89937daa6c6b6180f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:12:15 +0000 Subject: [PATCH 109/206] fix: import EmojiSuggestionState from commons in ShowEmojiSuggestionList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ShowEmojiSuggestionList sat in the same package as EmojiSuggestionState, so it referenced the class without an import — moving the class to commons broke the Android compile. Caught by an honest verification run: earlier gradle invocations were targeting a nonexistent :amethyst:compileDebugKotlin task (the app builds flavored compilePlayDebugKotlin/compileFdroidDebugKotlin) with the failure masked behind a pipe, so the last three commits had never actually been compiled. Verified at this commit: quartz nip85 tests (13/13), :commons:compileKotlinJvm, :cli:compileKotlin, :amethyst:compilePlayDebugKotlin, :desktopApp:compileKotlin. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt index b6e6ec3e8a..953a044747 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt @@ -43,6 +43,7 @@ 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.nip30CustomEmojis.EmojiPackState +import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.Size10dp From b81116865c5bd55c3bb22f618cc5a0a55ed0559b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:32:01 +0000 Subject: [PATCH 110/206] feat(ui): add density previews for the note-header marker system NoteHeaderFirstRowDensityPreview stacks first-row samples from bare (username + time + options) to fully loaded (hashtag, edited, draft, lock, pin, location/PoW/OTS/expiration pills, jump-to-parent) under a long username, in dark and light themes. Uses the real QuietMark/ HeaderPill components with static stand-ins only for the data-loading markers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../note/elements/NoteHeaderMarkersPreview.kt | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt new file mode 100644 index 0000000000..584c725b2a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -0,0 +1,188 @@ +/* + * 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.note.elements + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +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.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +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.ui.note.DisplayDraft +import com.vitorpamplona.amethyst.ui.note.DisplayExpiration +import com.vitorpamplona.amethyst.ui.note.PinnedMark +import com.vitorpamplona.amethyst.ui.note.PrivateRumorMark +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.lessImportantLink +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Design-system preview for the note-header first row: how the two marker + * tiers ([HeaderPill] and [QuietMark]) compose next to the username and + * timestamp as the line fills up. Uses the same layout contract as + * `FirstUserInfoRow` (weight-1 username, `Arrangement.spacedBy(5.dp)`), + * with static stand-ins only for the data-loading markers (location, OTS, + * hashtag). + */ +@Preview(widthDp = 400) +@Composable +fun NoteHeaderFirstRowDensityPreview() { + ThemeComparisonColumn { + Column(Modifier.padding(vertical = 6.dp)) { + // Bare minimum: username + time + options + PreviewHeaderRow {} + + // One quiet mark: repost + PreviewHeaderRow { BoostedMark() } + + // Quiet text marks: edited + draft + PreviewHeaderRow { + QuietMark(text = stringRes(R.string.edited), onClick = {}) + DisplayDraft() + } + + // Quiet icon marks: private rumor + pinned + PreviewHeaderRow { + PrivateRumorMark() + PinnedMark() + } + + // Soft link + one pill + PreviewHeaderRow { + PreviewHashtag() + DisplayPoW(24) + } + + // Pill cluster: location + PoW + OTS + PreviewHeaderRow { + PreviewLocationPill() + DisplayPoW(28) + PreviewOtsPill() + } + + // Expiring note with a parent to jump to + PreviewHeaderRow { + DisplayExpiration(TimeUtils.now() + 7200) + PreviewJumpToParent() + } + + // Kitchen sink under a long username: everything competes for space + PreviewHeaderRow(username = "A user with a very long display name") { + PreviewHashtag() + QuietMark(text = stringRes(R.string.edited), onClick = {}) + DisplayDraft() + PrivateRumorMark() + PinnedMark() + PreviewLocationPill() + DisplayPoW(32) + PreviewOtsPill() + DisplayExpiration(TimeUtils.now() + 7200) + PreviewJumpToParent() + } + } + } +} + +/** Same layout contract as `FirstUserInfoRow`: weight-1 bold username, 5dp gaps, time + options at the end. */ +@Composable +private fun PreviewHeaderRow( + username: String = "Vitor", + markers: @Composable RowScope.() -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), + ) { + Text( + text = username, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + markers() + + TimeAgo(TimeUtils.now() - 300, style = TimeAgoStyle.Short, fontSize = Font12SP) + + VerticalDotsIcon() + } +} + +/** Static stand-in for `DisplayFollowingHashtagsInPost` (which needs account state). */ +@Composable +private fun PreviewHashtag() { + Text( + text = "#nostr", + color = MaterialTheme.colorScheme.lessImportantLink, + fontSize = Font12SP, + maxLines = 1, + ) +} + +/** Static stand-in for `DisplayLocation` (which resolves the geohash to a city name). */ +@Composable +private fun PreviewLocationPill() { + HeaderPill( + symbol = MaterialSymbols.LocationOn, + text = "Belo Horizonte", + onClick = {}, + ) +} + +/** Static stand-in for `DisplayOts` (which loads and verifies the attestation). */ +@Composable +private fun PreviewOtsPill() { + HeaderPill( + symbol = MaterialSymbols.CheckCircle, + text = stringRes(R.string.existed_since, "2y"), + onClick = {}, + ) +} + +/** Static stand-in for `JumpToParentReplyButton` (which needs the parent note). */ +@Composable +private fun PreviewJumpToParent() { + Icon( + symbol = MaterialSymbols.KeyboardArrowUp, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.placeholderText, + ) +} From 3cba5d3b65348744a7a05994b3e04f9fb6577aea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:53:03 +0000 Subject: [PATCH 111/206] refactor(ui): drive header-marker previews through the real FirstUserInfoRow Replace the hand-built preview rows and static stand-ins with the actual FirstUserInfoRow over notes seeded into LocalCache (the ZapPollNote/Poll preview pattern): metadata for usernames, a repost, an empty-sig rumor, a draft wrap, a community post, and PoW ids with committed nonces plus geohash/expiration tags. The geohash resolves through a seeded CachedReversedGeoLocations entry and the OTS pill renders its real pending branch via account.settings.pendingAttestations. Only the followed-hashtag label and the confirmed OTS state stay out - both are computed in effects that static previews never run, as documented in the file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../note/elements/NoteHeaderMarkersPreview.kt | 319 ++++++++++-------- 1 file changed, 186 insertions(+), 133 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 584c725b2a..ea1c4287df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -20,169 +20,222 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -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.ui.note.DisplayDraft -import com.vitorpamplona.amethyst.ui.note.DisplayExpiration -import com.vitorpamplona.amethyst.ui.note.PinnedMark -import com.vitorpamplona.amethyst.ui.note.PrivateRumorMark -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font12SP +import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.location.CachedReversedGeoLocations +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.FirstUserInfoRow +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.note.types.EmptyState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink -import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +private val AUTHOR = "a".repeat(64) +private val LONG_NAME_AUTHOR = "b".repeat(64) + +private val AUTHOR_METADATA_ID = "e1".repeat(32) +private val LONG_NAME_METADATA_ID = "e2".repeat(32) + +private val PLAIN_ID = "1".repeat(64) +private val REPOST_ID = "2".repeat(64) +private val RUMOR_ID = "3".repeat(64) +private val EDITED_ID = "4".repeat(64) +private val EDIT_VERSION_ID = "5".repeat(64) +private val DRAFT_ID = "6".repeat(64) +private val COMMUNITY_POST_ID = "7".repeat(64) + +// 24 leading zero bits so strongPoWOrNull(min = 20) accepts the committed nonce. +private val POW_ID = "0".repeat(6) + "8".repeat(58) +private val KITCHEN_SINK_ID = "0".repeat(6) + "9".repeat(58) + +private const val GEOHASH = "u1x1" +private val COMMUNITY_ADDRESS = "34550:" + "c".repeat(64) + ":amethyst-users" /** - * Design-system preview for the note-header first row: how the two marker - * tiers ([HeaderPill] and [QuietMark]) compose next to the username and - * timestamp as the line fills up. Uses the same layout contract as - * `FirstUserInfoRow` (weight-1 username, `Arrangement.spacedBy(5.dp)`), - * with static stand-ins only for the data-loading markers (location, OTS, - * hashtag). + * Design-system preview for the note-header first row, rendered by the REAL + * `FirstUserInfoRow` over notes seeded into [LocalCache] — the same pattern + * the ZapPollNote/Poll previews use. Rows go from bare to fully loaded so the + * two marker tiers ([HeaderPill] and [QuietMark]) can be reviewed together. + * + * Two markers cannot appear in a static preview because their visibility is + * computed inside effects that previews never run: the followed-hashtag label + * (needs the account follow-list flows) and the *confirmed* OTS pill (needs + * attestation verification). The community label and the *pending* OTS pill + * shown here exercise the same two visual styles through real code. */ @Preview(widthDp = 400) @Composable fun NoteHeaderFirstRowDensityPreview() { + val accountViewModel = mockAccountViewModel() + val nav = EmptyNav() + + // The jump-to-parent arrow only renders in complete UI mode. + accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE + + // Let DisplayLocation resolve the geohash synchronously from the cache. + CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte") + + val now = TimeUtils.now() + val expiresAt = (now + 7200).toString() + + val plain: Note + val repost: Note + val rumorPinned: Note + val edited: Note + val editVersion: Note + val draft: Note + val communityPost: Note + val geoPowExpiring: Note + val kitchenSink: Note + + runBlocking { + withContext(Dispatchers.IO) { + LocalCache.justConsume( + MetadataEvent(AUTHOR_METADATA_ID, AUTHOR, now, emptyArray(), """{"name":"Vitor"}""", "x"), + null, + false, + ) + LocalCache.justConsume( + MetadataEvent(LONG_NAME_METADATA_ID, LONG_NAME_AUTHOR, now, emptyArray(), """{"name":"A user with a very long display name"}""", "x"), + null, + false, + ) + + LocalCache.justConsume(TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x"), null, false) + LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, false) + LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), "", "x"), null, false) + // An empty sig is what marks a note as a private rumor. + LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, false) + LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, false) + LocalCache.justConsume(DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, emptyArray(), "", "x"), null, false) + LocalCache.justConsume( + TextNoteEvent(COMMUNITY_POST_ID, AUTHOR, now - 300, arrayOf(arrayOf("a", COMMUNITY_ADDRESS)), "hello community", "x"), + null, + false, + ) + LocalCache.justConsume( + TextNoteEvent( + POW_ID, + AUTHOR, + now - 300, + arrayOf( + arrayOf("g", GEOHASH), + arrayOf("nonce", "776", "24"), + arrayOf("expiration", expiresAt), + ), + "mined and placed", + "x", + ), + null, + false, + ) + LocalCache.justConsume( + TextNoteEvent( + KITCHEN_SINK_ID, + LONG_NAME_AUTHOR, + now - 300, + arrayOf( + arrayOf("e", PLAIN_ID), + arrayOf("a", COMMUNITY_ADDRESS), + arrayOf("g", GEOHASH), + arrayOf("nonce", "776", "24"), + arrayOf("expiration", expiresAt), + ), + "everything everywhere all at once", + // Empty sig: also a private rumor. + "", + ), + null, + false, + ) + + plain = LocalCache.getOrCreateNote(PLAIN_ID) + repost = LocalCache.getOrCreateNote(REPOST_ID) + rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) + edited = LocalCache.getOrCreateNote(EDITED_ID) + editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) + draft = LocalCache.getOrCreateNote(DRAFT_ID) + communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) + geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) + kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) + } + } + + // LoadOts renders the pending pill synchronously from this map; the + // confirmed pill needs async verification a static preview can't run. + accountViewModel.account.settings.pendingAttestations + .update { it + (POW_ID to "stamp") + (KITCHEN_SINK_ID to "stamp") } + + val editedState = + remember { + mutableStateOf>( + GenericLoadable.Loaded(EditState().apply { updateModifications(listOf(editVersion)) }), + ) + } + ThemeComparisonColumn { - Column(Modifier.padding(vertical = 6.dp)) { + Column(Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { // Bare minimum: username + time + options - PreviewHeaderRow {} + HeaderRowSample(plain, accountViewModel) // One quiet mark: repost - PreviewHeaderRow { BoostedMark() } - - // Quiet text marks: edited + draft - PreviewHeaderRow { - QuietMark(text = stringRes(R.string.edited), onClick = {}) - DisplayDraft() - } + HeaderRowSample(repost, accountViewModel) // Quiet icon marks: private rumor + pinned - PreviewHeaderRow { - PrivateRumorMark() - PinnedMark() - } + HeaderRowSample(rumorPinned, accountViewModel, isPinned = true) - // Soft link + one pill - PreviewHeaderRow { - PreviewHashtag() - DisplayPoW(24) - } + // Quiet text mark, tappable: edited + HeaderRowSample(edited, accountViewModel, editState = editedState) - // Pill cluster: location + PoW + OTS - PreviewHeaderRow { - PreviewLocationPill() - DisplayPoW(28) - PreviewOtsPill() - } + // Quiet text mark: draft + HeaderRowSample(draft, accountViewModel) - // Expiring note with a parent to jump to - PreviewHeaderRow { - DisplayExpiration(TimeUtils.now() + 7200) - PreviewJumpToParent() - } + // Soft link: community label + HeaderRowSample(communityPost, accountViewModel) + + // Pill cluster: location + PoW + pending OTS + expiration + HeaderRowSample(geoPowExpiring, accountViewModel) // Kitchen sink under a long username: everything competes for space - PreviewHeaderRow(username = "A user with a very long display name") { - PreviewHashtag() - QuietMark(text = stringRes(R.string.edited), onClick = {}) - DisplayDraft() - PrivateRumorMark() - PinnedMark() - PreviewLocationPill() - DisplayPoW(32) - PreviewOtsPill() - DisplayExpiration(TimeUtils.now() + 7200) - PreviewJumpToParent() - } + HeaderRowSample(kitchenSink, accountViewModel, isPinned = true, editState = editedState) } } } -/** Same layout contract as `FirstUserInfoRow`: weight-1 bold username, 5dp gaps, time + options at the end. */ @Composable -private fun PreviewHeaderRow( - username: String = "Vitor", - markers: @Composable RowScope.() -> Unit, +private fun HeaderRowSample( + note: Note, + accountViewModel: AccountViewModel, + isPinned: Boolean = false, + editState: State> = EmptyState, ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp), - ) { - Text( - text = username, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - markers() - - TimeAgo(TimeUtils.now() - 300, style = TimeAgoStyle.Short, fontSize = Font12SP) - - VerticalDotsIcon() - } -} - -/** Static stand-in for `DisplayFollowingHashtagsInPost` (which needs account state). */ -@Composable -private fun PreviewHashtag() { - Text( - text = "#nostr", - color = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font12SP, - maxLines = 1, - ) -} - -/** Static stand-in for `DisplayLocation` (which resolves the geohash to a city name). */ -@Composable -private fun PreviewLocationPill() { - HeaderPill( - symbol = MaterialSymbols.LocationOn, - text = "Belo Horizonte", - onClick = {}, - ) -} - -/** Static stand-in for `DisplayOts` (which loads and verifies the attestation). */ -@Composable -private fun PreviewOtsPill() { - HeaderPill( - symbol = MaterialSymbols.CheckCircle, - text = stringRes(R.string.existed_since, "2y"), - onClick = {}, - ) -} - -/** Static stand-in for `JumpToParentReplyButton` (which needs the parent note). */ -@Composable -private fun PreviewJumpToParent() { - Icon( - symbol = MaterialSymbols.KeyboardArrowUp, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.placeholderText, + FirstUserInfoRow( + baseNote = note, + showAuthorPicture = false, + isPinned = isPinned, + editState = editState, + accountViewModel = accountViewModel, + nav = EmptyNav(), ) } From aa34f9cb0e1f1589e1f6b83f680caa6a42a92caa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:56:03 +0000 Subject: [PATCH 112/206] feat: track PoW, Tor, calls, decrypts, remote signs, and battery drain in the resource ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New counters for the app's remaining heavy battery consumers, all transition-based so tracking never adds work to the activities measured: - pow.ms/sessions: NIP-13 mining time from the PoW queue's isMining flow — the largest attributable CPU burner, previously an unattributed cpu.ms mystery - tor.ms/starts: in-app Arti uptime from the raw TorService status (not TorManager.status, whose WhileSubscribed upstream would keep Tor's control flow alive if the ledger subscribed to it) - service.alwayson.ms, call.ms/sessions, nests.ms/sessions: foreground service lifecycles for the always-on relay service, calls, and NIP-53 audio rooms - crypto.decrypt/encrypt.count+us and sign.local/nip46/nip55.count via a MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at account load; crypto durations only metered for local-key signers so IPC/relay waits never poison the CPU numbers - location.ms: GPS listening segments around LocationFlow collection - battery.drain.fg/bg: measured percent-while-discharging sampled at each flush — the ground truth to correlate the other counters against Ledger infrastructure hardening the new counters build on: - all duration math now uses SystemClock.elapsedRealtime; wall-clock jumps (NTP, timezone) no longer fabricate or delete accounted time - pre-flush-hook adds are drained in place and no longer re-arm the debounced flush, killing a self-perpetuating 30s wake-and-write loop - drain is now AtomicLong.getAndSet(0) instead of remove+sum, closing a lost-update race that undercounted the hottest counters - shared TimeSegmentIntegrator base extracted; relay/foreground integrators and the new SessionTimeIntegrator all use it - @Volatile on LocalCache.verifyMeter (matching the onchainBackend precedent) New counters surface in the usage screen (zero rows hidden), the NIP-17 report tables, and the per-day dump. 9 new unit tests including a regression test for the flush loop. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 25 ++ .../com/vitorpamplona/amethyst/AppModules.kt | 63 ++++- .../amethyst/model/LocalCache.kt | 1 + .../model/accountsCache/AccountCacheState.kt | 4 +- .../service/call/CallForegroundService.kt | 3 + .../service/location/LocationState.kt | 6 + .../service/nests/NestForegroundService.kt | 3 + .../notifications/NotificationRelayService.kt | 2 + .../resourceusage/BatteryDrainSampler.kt | 66 +++++ .../resourceusage/ForegroundTimeIntegrator.kt | 42 +-- .../resourceusage/MeteringNostrSigner.kt | 138 +++++++++ .../RelayConnectionTimeIntegrator.kt | 41 +-- .../resourceusage/ResourceUsageAccountant.kt | 46 ++- .../ResourceUsageReportAssembler.kt | 8 + .../resourceusage/TimeSegmentIntegrator.kt | 106 +++++++ .../resourceusage/UsageCountingInterceptor.kt | 5 +- .../service/resourceusage/UsageKeys.kt | 45 +++ .../service/resourceusage/UsageSummary.kt | 24 ++ .../ui/screen/loggedIn/LoggedInPage.kt | 17 +- .../loggedIn/settings/ResourceUsageScreen.kt | 36 +++ amethyst/src/main/res/values/strings.xml | 9 + .../resourceusage/ResourceUsageLedgerTest.kt | 266 ++++++++++++++++++ 22 files changed, 859 insertions(+), 97 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 04db249238..9c7e641739 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -81,6 +81,31 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: segments): decoder + screen + streaming at once, the denominator for video bytes +- `pow.ms` / `pow.sessions` — NIP-13 mining time (any job mining in the + PoW queue): full-core CPU, the largest attributable CPU consumer +- `tor.ms` / `tor.starts` — in-app (Arti) Tor uptime and bootstraps, from the + raw TorService status (NOT TorManager.status, whose WhileSubscribed + upstream calls service.start() when collected). External Tor (Orbot) is + deliberately untracked — its battery belongs to Orbot +- `service.alwayson.ms` — NotificationRelayService uptime: the mode context + that explains a device's relay connection-time +- `call.ms`/`call.sessions`, `nests.ms`/`nests.sessions` — calls and NIP-53 + audio rooms (mic + Opus + live media connection), from the foreground + services' lifecycles +- `location.ms` — time actively listening for GPS updates (geohash tagging); + mostly a tripwire for a leaked location subscription +- `crypto.decrypt.count/us`, `crypto.encrypt.count/us` — NIP-04/44 work via a + MeteringNostrSigner decorator wrapped inside NostrSignerWithClientTag at + account load; durations metered only for local-key signers (external/ + remote waits are IPC/network, not CPU) +- `sign.local|nip46|nip55.count` — signatures by signer kind: NIP-46 is a + relay round-trip and NIP-55 an Amber IPC wake, so the kind is the + battery-relevant dimension (this supersedes "signing is negligible", which + is only true for local keys) +- `battery.drain.fg|bg` — measured battery percent while discharging, sampled + at flush from BatteryManager: NOT app-isolated, but the ground truth that + report corpora can correlate the other counters against + Deliberately not tracked (v1): per-screen time (route names leak behavior patterns into a report — needs its own privacy review), per-coroutine or per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 1c5e65cdf7..b36593c4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst import android.content.ComponentCallbacks2 import android.content.Context +import android.os.BatteryManager import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache @@ -86,15 +87,18 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscripti import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger +import com.vitorpamplona.amethyst.service.resourceusage.BatteryDrainSampler import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter +import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys import com.vitorpamplona.amethyst.service.safeCacheDir @@ -110,6 +114,7 @@ import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorService +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient @@ -226,7 +231,7 @@ class AppModules( // App services that should be run as soon as there are subscribers to their flows val locationManager by lazy { Log.d("AppModules", "LocationManager Init") - LocationState(appContext, applicationIOScope) + LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) }) } val connManager = ConnectivityManager(appContext, applicationIOScope) @@ -235,7 +240,8 @@ class AppModules( UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope) } - val torManager = TorManager(torPrefs, TorService(appContext), applicationIOScope) + private val torService = TorService(appContext) + val torManager = TorManager(torPrefs, torService, applicationIOScope) // Network identity change (wifi↔cellular, regained from offline, captive portal // cleared) — the old network's guards/circuits are dead, and Arti's in-memory @@ -323,6 +329,45 @@ class AppModules( private val httpUsageMeter = HttpUsageMeter() + // Session-time counters for the app's long-running battery consumers. + // All timer-free segment integrators: services and status flows flip them + // on/off, so tracking costs one counter write per transition. + val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS).also { it.registerFlushHook() } + val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() } + val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() } + private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() } + private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } + private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + + // In-app (Arti) Tor uptime. Watches the raw TorService status — NOT + // TorManager.status, whose upstream is WhileSubscribed and calls + // service.start() when collected, so a permanent ledger subscription + // there would keep Tor's control flow alive on its own. External Tor + // (Orbot) is deliberately untracked: its battery belongs to Orbot. + init { + applicationIOScope.launch { + torService.status + .map { it is TorServiceStatus.Active } + .distinctUntilChanged() + .collect { torSession.setActive(it) } + } + } + + // Measured battery drain (percent while discharging, fg/bg) — the ground + // truth the app counters get correlated against. One binder read per + // ledger flush, nothing while idle. + init { + val batteryManager = appContext.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + if (batteryManager != null) { + BatteryDrainSampler( + accountant = resourceUsage, + capacityPct = { batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY).takeIf { it in 1..100 } }, + isCharging = { batteryManager.isCharging }, + isForeground = { foregroundTracker.isForeground.value }, + ).register() + } + } + // manages all the other connections separately from relays. val okHttpClients: DualHttpClientManager = DualHttpClientManager( @@ -713,7 +758,18 @@ class AppModules( minerThreads = PoWPolicy.minerWorkers(Runtime.getRuntime().availableProcessors()), persistence = powJobStore, onQueueActive = { PowMiningForegroundService.start(appContext) }, - ) + ).also { queue -> + // Resource ledger: mining burns half the cores flat-out for as + // long as it runs — without this, PoW shows up in cpu.ms as an + // unattributed mystery. Wired inside the lazy so the ledger never + // forces the queue to initialize. + applicationIOScope.launch { + queue.jobs + .map { jobs -> jobs.any { it.isMining } } + .distinctUntilChanged() + .collect { powSession.setActive(it) } + } + } } val powJobRestorer by lazy { @@ -734,6 +790,7 @@ class AppModules( client = client, rootFilesDir = { appContext.filesDir }, powQueue = { powPublishQueue }, + meterSigner = { MeteringNostrSigner(it, resourceUsage) }, ) val sessionManager = 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 2dbba0d893..33b97556fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3221,6 +3221,7 @@ object LocalCache : ILocalCache, ICacheProvider { * signature verification so the app can account crypto CPU per day. * Wired by AppModules like [onchainBackend]; null costs nothing. */ + @Volatile var verifyMeter: ((elapsedNanos: Long, valid: Boolean) -> Unit)? = null fun justVerify(event: Event): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 32a6183eec..9db6a326c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -63,6 +63,8 @@ class AccountCacheState( val client: INostrClient, val rootFilesDir: () -> File = { File("") }, val powQueue: () -> PoWPublishQueue? = { null }, + /** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */ + val meterSigner: (NostrSigner) -> NostrSigner = { it }, ) { val accounts = MutableStateFlow>(emptyMap()) @@ -175,7 +177,7 @@ class AccountCacheState( val signerWithClientTag = NostrSignerWithClientTag( - inner = signer, + inner = meterSigner(signer), clientName = CLIENT_TAG_NAME, disabled = { !accountSettings.syncedSettings.security.addClientTag.value }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 17d22eea59..37107a5ed0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -34,6 +34,7 @@ import android.os.IBinder import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.ui.call.CallActivity @@ -61,6 +62,7 @@ class CallForegroundService : Service() { override fun onCreate() { super.onCreate() + Amethyst.instance.callSession.setActive(true) createNotificationChannel() } @@ -151,6 +153,7 @@ class CallForegroundService : Service() { // because CallManager.hangup() transitions to Ended and a second // hangup() from Ended state returns immediately. publishHangupBlocking() + Amethyst.instance.callSession.setActive(false) super.onDestroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt index 298799e119..a5a330ae61 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/location/LocationState.kt @@ -31,13 +31,17 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest class LocationState( context: Context, scope: CoroutineScope, + /** Resource-ledger hook: true while GPS/location updates are actively requested. */ + private val onListening: ((Boolean) -> Unit)? = null, ) { companion object { const val MIN_TIME: Long = 10000L @@ -72,6 +76,8 @@ class LocationState( val result = LocationFlow(context) .get(MIN_TIME, MIN_DISTANCE) + .onStart { onListening?.invoke(true) } + .onCompletion { onListening?.invoke(false) } .map { LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult }.onEach { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt index cb6ac03f08..b629c41546 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt @@ -39,6 +39,7 @@ import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState @@ -106,6 +107,7 @@ class NestForegroundService : Service() { override fun onCreate() { super.onCreate() + Amethyst.instance.nestsSession.setActive(true) createNotificationChannel() wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager) @@ -421,6 +423,7 @@ class NestForegroundService : Service() { } override fun onDestroy() { + Amethyst.instance.nestsSession.setActive(false) wakeLock?.takeIf { it.isHeld }?.release() wakeLock = null abandonAudioFocus() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index de2b6c2694..c255ea4b36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -138,6 +138,7 @@ class NotificationRelayService : Service() { override fun onCreate() { super.onCreate() Log.d(TAG, "Service created") + Amethyst.instance.alwaysOnSession.setActive(true) createNotificationChannel() ensureForeground() } @@ -197,6 +198,7 @@ class NotificationRelayService : Service() { */ override fun onDestroy() { Log.d(TAG, "Service destroyed") + Amethyst.instance.alwaysOnSession.setActive(false) relayServiceCollectorJob?.cancel() scope.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.kt new file mode 100644 index 0000000000..5487055071 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/BatteryDrainSampler.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.amethyst.service.resourceusage + +/** + * Samples the device battery level at each ledger flush and accumulates the + * drops that happen while discharging into [UsageKeys.BATTERY_DRAIN_FG] / + * [UsageKeys.BATTERY_DRAIN_BG] (percent points, attributed by visibility at + * sample time). This is deliberately NOT app-isolated — it's the measured + * ground truth that lets the developers correlate the app's own counters + * against real drain across many reports, which is how the alert thresholds + * get tuned. + * + * Intervals touching a charging state (at either endpoint) are skipped, and + * a level that went UP just resets the baseline. Piggybacks on the pre-flush + * hook — one BatteryManager binder read per flush, nothing while idle. + */ +class BatteryDrainSampler( + private val accountant: ResourceUsageAccountant, + private val capacityPct: () -> Int?, + private val isCharging: () -> Boolean, + private val isForeground: () -> Boolean, +) { + private var lastPct: Int? = null + private var lastCharging = true + + fun register() { + accountant.addPreFlushHook(::sample) + } + + @Synchronized + fun sample() { + val pct = capacityPct() ?: return + val charging = isCharging() + val prevPct = lastPct + val prevCharging = lastCharging + lastPct = pct + lastCharging = charging + + if (prevPct == null || prevCharging || charging) return + val drop = prevPct - pct + if (drop <= 0) return + accountant.add( + if (isForeground()) UsageKeys.BATTERY_DRAIN_FG else UsageKeys.BATTERY_DRAIN_BG, + drop.toLong(), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt index 125951ef8a..0e1bb3200c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ForegroundTimeIntegrator.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -29,45 +30,24 @@ import kotlinx.coroutines.launch * Integrates time-with-UI-visible into the ledger ([UsageKeys.APP_FG_MS]). * Screen-on time is what display power is proportional to, and it's the * denominator that makes every other counter interpretable (MB per hour in - * app vs MB while backgrounded). Same timer-free segment pattern as - * [RelayConnectionTimeIntegrator]: segments close on transitions and on the - * accountant's pre-flush hook. + * app vs MB while backgrounded). */ class ForegroundTimeIntegrator( private val isForeground: Flow, - private val accountant: ResourceUsageAccountant, - private val nowMs: () -> Long = { System.currentTimeMillis() }, -) { - private val lock = Any() - private var foreground = false - private var segmentStartMs = 0L - + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { fun start(scope: CoroutineScope): Job { - accountant.addPreFlushHook(::closeOpenSegment) + registerFlushHook() return scope.launch { - isForeground.collect { fg -> transitionTo(fg) } + isForeground.collect { fg -> transitionTo(if (fg) Unit else null) } } } - fun closeOpenSegment() { - synchronized(lock) { - if (!foreground) return - val now = nowMs() - account(now - segmentStartMs) - segmentStartMs = now - } - } - - private fun transitionTo(fg: Boolean) { - synchronized(lock) { - val now = nowMs() - if (foreground) account(now - segmentStartMs) - foreground = fg - segmentStartMs = now - } - } - - private fun account(elapsedMs: Long) { + override fun account( + state: Unit, + elapsedMs: Long, + ) { if (elapsedMs > 0) accountant.add(UsageKeys.APP_FG_MS, elapsedMs) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt new file mode 100644 index 0000000000..93bb31f4a5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/MeteringNostrSigner.kt @@ -0,0 +1,138 @@ +/* + * 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.service.resourceusage + +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.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag + +/** + * A [NostrSigner] decorator (same pattern as [NostrSignerWithClientTag]) that + * accounts signing and encryption work into the resource-usage ledger: + * + * - signature counts by signer kind — a local-key signature is ~free, a + * NIP-55 one wakes the Amber process over IPC, and a NIP-46 one is a full + * relay round-trip, so the *kind* is the battery-relevant dimension; + * - NIP-04/44 decrypt/encrypt counts, with CPU time metered only when the + * wrapped signer is a local key ([NostrSignerInternal]) — for external and + * remote signers the elapsed time would be IPC/network wait, not crypto + * cost, and would poison the CPU numbers. + * + * Overhead per operation: one counter increment (plus two [System.nanoTime] + * calls for local-key crypto, nanoseconds against a millisecond-scale ECDH) — + * nothing here can slow the operations being measured. + * + * Wrapped INSIDE [NostrSignerWithClientTag] (metering the raw signer), so the + * existing `signer is NostrSignerWithClientTag` call sites keep working; + * anything that needs the raw signer should unwrap via [innermostSigner]. + */ +class MeteringNostrSigner( + val inner: NostrSigner, + private val accountant: ResourceUsageAccountant, +) : NostrSigner(inner.pubKey) { + private val signKey = UsageKeys.signs(signerKind(inner)) + private val meterCryptoTime = inner is NostrSignerInternal + + override fun isWriteable(): Boolean = inner.isWriteable() + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + accountant.add(signKey, 1) + return inner.sign(createdAt, kind, tags, content) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip04Encrypt(plaintext, toPublicKey) } + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip04Decrypt(ciphertext, fromPublicKey) } + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String = metered(UsageKeys.ENCRYPT_COUNT, UsageKeys.ENCRYPT_US) { inner.nip44Encrypt(plaintext, toPublicKey) } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.nip44Decrypt(ciphertext, fromPublicKey) } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = metered(UsageKeys.DECRYPT_COUNT, UsageKeys.DECRYPT_US) { inner.decryptZapEvent(event) } + + override suspend fun deriveKey(nonce: HexKey): HexKey = inner.deriveKey(nonce) + + override suspend fun signPsbt(psbtHex: String): String { + accountant.add(signKey, 1) + return inner.signPsbt(psbtHex) + } + + override fun hasForegroundSupport(): Boolean = inner.hasForegroundSupport() + + private inline fun metered( + countKey: String, + usKey: String, + op: () -> T, + ): T { + accountant.add(countKey, 1) + if (!meterCryptoTime) return op() + val start = System.nanoTime() + try { + return op() + } finally { + accountant.add(usKey, (System.nanoTime() - start) / 1_000) + } + } + + companion object { + fun signerKind(signer: NostrSigner): String = + when (signer) { + is NostrSignerExternal -> UsageKeys.SIGNER_NIP55 + is NostrSignerRemote -> UsageKeys.SIGNER_NIP46 + else -> UsageKeys.SIGNER_LOCAL + } + } +} + +/** + * Strips the app's signer decorators (client tag, metering) to reach the + * concrete signer — for call sites that need the real type, like the NIP-55 + * activity-launcher registration. + */ +fun NostrSigner.innermostSigner(): NostrSigner = + when (this) { + is NostrSignerWithClientTag -> inner.innermostSigner() + is MeteringNostrSigner -> inner.innermostSigner() + else -> this + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt index 7fea7880eb..b3afc9c6e5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayConnectionTimeIntegrator.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow @@ -31,31 +32,22 @@ import kotlinx.coroutines.launch * the ledger, split by network class and visibility. Connection-time is the * best single battery proxy the ping study found: most relays server-ping * every 30-70s, so the radio is active for as long as connections are open. - * - * No timers: the integral is exact between state changes (the connection - * count is constant), so a segment is closed only when any input changes — - * plus on [closeOpenSegment], which the accountant calls before every flush - * and read so multi-hour stable sessions still account. */ class RelayConnectionTimeIntegrator( private val connectedCount: Flow, private val isMobile: Flow, private val isForeground: Flow, - private val accountant: ResourceUsageAccountant, - private val nowMs: () -> Long = { System.currentTimeMillis() }, -) { - private data class SegmentState( + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + data class SegmentState( val count: Int, val mobile: Boolean, val foreground: Boolean, ) - private val lock = Any() - private var current: SegmentState? = null - private var segmentStartMs: Long = 0L - fun start(scope: CoroutineScope): Job { - accountant.addPreFlushHook(::closeOpenSegment) + registerFlushHook() return scope.launch { combine(connectedCount, isMobile, isForeground) { count, mobile, fg -> SegmentState(count, mobile ?: false, fg) @@ -63,26 +55,7 @@ class RelayConnectionTimeIntegrator( } } - /** Accounts the running segment up to now without changing state. */ - fun closeOpenSegment() { - synchronized(lock) { - val state = current ?: return - val now = nowMs() - account(state, now - segmentStartMs) - segmentStartMs = now - } - } - - private fun transitionTo(next: SegmentState) { - synchronized(lock) { - val now = nowMs() - current?.let { account(it, now - segmentStartMs) } - current = next - segmentStartMs = now - } - } - - private fun account( + override fun account( state: SegmentState, elapsedMs: Long, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index 0b19a1bf1d..778532c29a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -25,15 +25,27 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.LongAdder +import java.util.concurrent.atomic.AtomicLong /** * In-memory hot path for usage counters. [add] is called from network threads * per relay frame / HTTP response chunk, so it must be allocation-light and - * lock-free: a ConcurrentHashMap of LongAdders, drained into + * lock-free: a ConcurrentHashMap of AtomicLongs, drained into * [ResourceUsageStore] by a debounced flush (at most one write per * [flushDebounceMs] while traffic flows; nothing scheduled when idle). * + * AtomicLong (not LongAdder) because draining must be loss-free: getAndSet(0) + * hands off the accumulated value atomically, whereas remove+sum on a + * LongAdder can strand a racing increment on an orphaned cell. Entries stay + * in the map after a drain — the key space is small and fixed (dims x areas), + * so this costs a few hundred boxed zeros at most. + * + * Counters added from inside a pre-flush hook (the CPU sampler, the segment + * integrators closing an open segment) never re-arm the debounce: they are + * drained by the very flush that invoked the hook, and letting them schedule + * would turn every flush into a perpetual 30s wake-and-write loop — the + * battery ledger becoming its own battery drain. + * * Day attribution: deltas are drained into the bucket of the day they are * drained on. Counts within one debounce window of midnight may land on the * neighboring day — irrelevant at the ledger's day-level granularity. @@ -44,9 +56,12 @@ class ResourceUsageAccountant( private val epochDay: () -> Long = { System.currentTimeMillis() / DAY_MS }, private val flushDebounceMs: Long = 30_000L, ) { - private val live = ConcurrentHashMap() + private val live = ConcurrentHashMap() private val flushScheduled = AtomicBoolean(false) + /** True only on the thread currently running the pre-flush hooks. */ + private val inHookRun = ThreadLocal.withInitial { false } + /** Hooks run right before a flush drains the counters (e.g. the connection-time integrator closing its open segment). */ private val preFlushHooks = ConcurrentHashMap.newKeySet<() -> Unit>() @@ -59,7 +74,8 @@ class ResourceUsageAccountant( amount: Long, ) { if (amount <= 0) return - live.computeIfAbsent(key) { LongAdder() }.add(amount) + live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount) + if (inHookRun.get()) return if (flushScheduled.compareAndSet(false, true)) { scope.launch { delay(flushDebounceMs) @@ -69,9 +85,18 @@ class ResourceUsageAccountant( } } + private fun runPreFlushHooks() { + inHookRun.set(true) + try { + preFlushHooks.forEach { runCatching { it() } } + } finally { + inHookRun.set(false) + } + } + /** Drains the in-memory counters into today's persisted bucket. */ suspend fun flush() { - preFlushHooks.forEach { runCatching { it() } } + runPreFlushHooks() val deltas = drain() if (deltas.isNotEmpty()) { store.mergeInto(epochDay(), deltas) @@ -88,12 +113,12 @@ class ResourceUsageAccountant( /** * Persisted buckets merged with the not-yet-flushed live counters * (attributed to today). This is what the UI, the report assembler, - * and the alert evaluator read. + * and the alert evaluator read. Reading never schedules a flush. */ suspend fun allDaysIncludingLive(): Map> { - preFlushHooks.forEach { runCatching { it() } } + runPreFlushHooks() val persisted = store.allDays() - val liveSnapshot = live.mapValues { it.value.sum() }.filterValues { it > 0 } + val liveSnapshot = live.mapValues { it.value.get() }.filterValues { it > 0 } if (liveSnapshot.isEmpty()) return persisted val today = epochDay() val merged = persisted.toMutableMap() @@ -106,9 +131,8 @@ class ResourceUsageAccountant( private fun drain(): Map { if (live.isEmpty()) return emptyMap() val deltas = mutableMapOf() - for (key in live.keys) { - val adder = live.remove(key) ?: continue - val value = adder.sum() + for ((key, counter) in live) { + val value = counter.getAndSet(0L) if (value > 0) deltas[key] = value } return deltas diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 73ba5d434e..6c224b9f1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -98,7 +98,15 @@ class ResourceUsageReportAssembler { append("| Relay reconnections | ${s.relayConnects} (${s.relayConnectFails} failed) |\n") append("| Web requests | ${s.httpRequests} (${s.radioBursts} radio bursts, ${formatDurationMs(s.httpActiveMs)} active) |\n") append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") + append("| PoW mining | ${formatDurationMs(s.powMs)} |\n") + append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n") + append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} |\n") + append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n") + append("| Location listening | ${formatDurationMs(s.locationMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") + append("| Decryptions | ${s.decryptCount} (${formatDurationMs(s.decryptUs / 1_000)} CPU) |\n") + append("| Remote signatures | ${s.signNip46} NIP-46, ${s.signNip55} NIP-55 |\n") + append("| Battery drain (measured, whole device) | ${s.batteryDrainFg}% in app, ${s.batteryDrainBg}% background |\n") append("| App CPU time | ${formatDurationMs(s.cpuMs)} |\n") append("| Time in app | ${formatDurationMs(s.foregroundMs)} |\n") append("| Background worker runs | ${s.workerRuns} |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt new file mode 100644 index 0000000000..c70ae6032c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/TimeSegmentIntegrator.kt @@ -0,0 +1,106 @@ +/* + * 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.service.resourceusage + +import android.os.SystemClock + +/** + * Timer-free duration integrator: the elapsed time of the current state is + * exact between transitions, so a segment is accounted only when the state + * changes — plus on the accountant's pre-flush hook, so multi-hour stable + * segments (a call, a backgrounded night with relays connected) still land in + * the right day bucket without any periodic timer. + * + * Durations are measured with [SystemClock.elapsedRealtime] — the monotonic + * clock — never the wall clock: an NTP/timezone correction mid-segment must + * not fabricate or delete accounted time. (Wall time is only ever used for + * choosing the epoch-day bucket, which happens in the accountant.) + * + * A `null` state means "nothing to account" (idle); subclasses decide what a + * non-null state costs per elapsed millisecond. + */ +abstract class TimeSegmentIntegrator( + protected val accountant: ResourceUsageAccountant, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) { + private val lock = Any() + private var current: S? = null + private var segmentStartMs = 0L + + /** Registers [closeOpenSegment] so flushes and reads see up-to-date totals. */ + fun registerFlushHook() { + accountant.addPreFlushHook(::closeOpenSegment) + } + + /** Accounts the running segment up to now without changing state. */ + fun closeOpenSegment() { + synchronized(lock) { + val state = current ?: return + val now = nowMs() + account(state, now - segmentStartMs) + segmentStartMs = now + } + } + + /** + * Accounts the previous segment and starts a new one. Returns the previous + * state so callers can detect activations (null -> non-null). + */ + fun transitionTo(next: S?): S? = + synchronized(lock) { + val now = nowMs() + val prev = current + prev?.let { account(it, now - segmentStartMs) } + current = next + segmentStartMs = now + prev + } + + protected abstract fun account( + state: S, + elapsedMs: Long, + ) +} + +/** + * The simplest integrator: total time a boolean condition is active (Tor + * running, a call in progress, GPS listening), written to [msKey] — plus an + * optional [startsKey] counting activations, since starts are often the + * expensive part (a Tor bootstrap, a service cold start). + */ +class SessionTimeIntegrator( + accountant: ResourceUsageAccountant, + private val msKey: String, + private val startsKey: String? = null, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + fun setActive(active: Boolean) { + val prev = transitionTo(if (active) Unit else null) + if (active && prev == null && startsKey != null) accountant.add(startsKey, 1) + } + + override fun account( + state: Unit, + elapsedMs: Long, + ) { + if (elapsedMs > 0) accountant.add(msKey, elapsedMs) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 37b198d2cf..3931d3d8e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.resourceusage +import android.os.SystemClock import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Response @@ -53,7 +54,7 @@ class RadioBurstEstimator( private val accountant: ResourceUsageAccountant, private val isMobile: () -> Boolean, private val isForeground: () -> Boolean, - private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) { @Volatile private var lastActivityMs = Long.MIN_VALUE @@ -91,7 +92,7 @@ class UsageCountingInterceptor( private val isForeground: () -> Boolean, private val bursts: RadioBurstEstimator? = null, private val defaultRole: String = UsageKeys.ROLE_OTHER, - private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val nowMs: () -> Long = { SystemClock.elapsedRealtime() }, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 2b0a799e21..0f8a93aa4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -127,6 +127,51 @@ object UsageKeys { /** Media (video/audio) playback time — decoder + screen + streaming all at once. */ const val MEDIA_PLAY_MS = "media.playms" + /** NIP-13 proof-of-work mining: full-core CPU for as long as it runs. */ + const val POW_MS = "pow.ms" + const val POW_SESSIONS = "pow.sessions" + + /** In-app (Arti) Tor: circuit crypto + directory/guard keep-alives while up; each start pays a bootstrap. */ + const val TOR_MS = "tor.ms" + const val TOR_STARTS = "tor.starts" + + /** Always-on notification relay service uptime — the mode context for its relay connections. */ + const val ALWAYS_ON_MS = "service.alwayson.ms" + + /** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */ + const val CALL_MS = "call.ms" + const val CALL_SESSIONS = "call.sessions" + const val NESTS_MS = "nests.ms" + const val NESTS_SESSIONS = "nests.sessions" + + /** Time spent actively listening for GPS/location updates (geohash tagging). */ + const val LOCATION_MS = "location.ms" + + /** + * NIP-04/44 decryptions and encryptions through account signers. Durations + * are only metered for local-key signers (CPU cost); external/remote + * signer waits are IPC/network, tracked by the sign/decrypt counts alone. + */ + const val DECRYPT_COUNT = "crypto.decrypt.count" + const val DECRYPT_US = "crypto.decrypt.us" + const val ENCRYPT_COUNT = "crypto.encrypt.count" + const val ENCRYPT_US = "crypto.encrypt.us" + + /** `sign.nip46.count` — signatures by signer kind: local key, NIP-55 (Amber IPC), NIP-46 (relay round-trip). */ + fun signs(kind: String): String = "sign.$kind.count" + + const val SIGNER_LOCAL = "local" + const val SIGNER_NIP46 = "nip46" + const val SIGNER_NIP55 = "nip55" + + /** + * Measured battery drain (percent points while discharging), split by + * visibility. Not app-isolated — it's the ground truth the other counters + * get correlated against across reports. + */ + const val BATTERY_DRAIN_FG = "battery.drain.fg" + const val BATTERY_DRAIN_BG = "battery.drain.bg" + fun dim( mobile: Boolean, foreground: Boolean, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 7a1cd0bb6a..72cb38117c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -50,6 +50,18 @@ data class UsageSummary( val radioBursts: Long, val httpActiveMs: Long, val mediaPlayMs: Long, + val powMs: Long, + val torMs: Long, + val alwaysOnMs: Long, + val callMs: Long, + val nestsMs: Long, + val locationMs: Long, + val decryptCount: Long, + val decryptUs: Long, + val signNip46: Long, + val signNip55: Long, + val batteryDrainFg: Long, + val batteryDrainBg: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, ) { @@ -95,6 +107,18 @@ data class UsageSummary( radioBursts = counters.sumMatching("bursts"), httpActiveMs = counters.sumMatching("activems"), mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, + powMs = counters[UsageKeys.POW_MS] ?: 0L, + torMs = counters[UsageKeys.TOR_MS] ?: 0L, + alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, + callMs = counters[UsageKeys.CALL_MS] ?: 0L, + nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L, + locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L, + decryptCount = counters[UsageKeys.DECRYPT_COUNT] ?: 0L, + decryptUs = counters[UsageKeys.DECRYPT_US] ?: 0L, + signNip46 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP46)] ?: 0L, + signNip55 = counters[UsageKeys.signs(UsageKeys.SIGNER_NIP55)] ?: 0L, + batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L, + batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L, bytesPerSubsystem = subsystems, ) } 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..79bb11bde3 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 @@ -47,11 +47,11 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayA import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.resourceusage.innermostSigner 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.quartz.nip55AndroidSigner.client.IActivityLauncher -import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -172,20 +172,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) { @Composable private fun ListenToExternalSignerIfNeeded(accountViewModel: AccountViewModel) { - val externalSignerLauncher = - when (val signer = accountViewModel.account.signer) { - is IActivityLauncher -> { - signer - } - - is NostrSignerWithClientTag if signer.inner is IActivityLauncher -> { - signer.inner as IActivityLauncher - } - - else -> { - null - } - } + val externalSignerLauncher = accountViewModel.account.signer.innermostSigner() as? IActivityLauncher if (externalSignerLauncher != null) { val launcher = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 0e416b735d..aac8e93e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -291,6 +291,42 @@ private fun ActivitySection(s: UsageSummary) { MetricRow(R.string.resource_usage_worker_runs, s.workerRuns.toString()) SettingsDivider() MetricRow(R.string.resource_usage_app_starts, s.appStarts.toString()) + if (s.decryptCount > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_decrypts, s.decryptCount.toString()) + } + if (s.signNip46 + s.signNip55 > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_remote_signs, (s.signNip46 + s.signNip55).toString()) + } + if (s.powMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_pow, formatDurationMs(s.powMs)) + } + if (s.torMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_tor, formatDurationMs(s.torMs)) + } + if (s.alwaysOnMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_always_on, formatDurationMs(s.alwaysOnMs)) + } + if (s.callMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_calls, formatDurationMs(s.callMs)) + } + if (s.nestsMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_nests, formatDurationMs(s.nestsMs)) + } + if (s.locationMs > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_location, formatDurationMs(s.locationMs)) + } + if (s.batteryDrainFg + s.batteryDrainBg > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_battery_drain, "${s.batteryDrainFg}% / ${s.batteryDrainBg}%") + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 429795778e..e3e5e34e8e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3209,6 +3209,15 @@ Processor time used Time in app App process starts + Messages decrypted + Remote signatures + Proof-of-work mining + Tor uptime + Always-on notification service + Time in calls + Time in audio rooms + Location listening + Battery used (in app / background) Data by feature (7 days) Relay sync Images diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index f99f1dc451..3a09f2064f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -21,6 +21,12 @@ package com.vitorpamplona.amethyst.service.resourceusage import com.vitorpamplona.amethyst.service.playback.playerPool.MediaPlayTimeTracker +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.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -122,6 +128,46 @@ class ResourceUsageAccountantTest { assertNull(store.allDays()[300]) } + @Test + fun countersKeepAccumulatingAfterADrain() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 200L }) + + accountant.add("x", 5) + accountant.flush() + accountant.add("x", 7) + accountant.flush() + + assertEquals(12L, store.allDays()[200]?.get("x")) + } + + @Test + fun hookAddsAreDrainedInPlaceAndNeverRearmTheFlushLoop() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 300L }) + var hookRuns = 0 + accountant.addPreFlushHook { + hookRuns++ + accountant.add("hook.counter", 1) + } + + accountant.add("x", 1) + testScheduler.advanceTimeBy(31_000) + testScheduler.runCurrent() + + assertEquals("the one debounced flush ran its hooks once", 1, hookRuns) + assertEquals(1L, store.allDays()[300]?.get("hook.counter")) + + // If hook adds re-armed the debounce, more flushes would fire and + // the hook counter would keep growing without any real activity. + testScheduler.advanceTimeBy(300_000) + testScheduler.runCurrent() + assertEquals("no self-perpetuating flush loop", 1, hookRuns) + assertEquals(1L, store.allDays()[300]?.get("hook.counter")) + } + @Test fun preFlushHooksRunOnFlushAndRead() = runTest { @@ -338,6 +384,226 @@ class MediaPlayTimeTrackerTest { } } +class SessionTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun accountsActiveTimeAndCountsActivations() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock }) + + session.setActive(true) + clock += 5_000 + session.setActive(false) + clock += 60_000 // idle time must not count + session.setActive(true) + clock += 1_000 + session.setActive(false) + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(6_000L, today["s.ms"]) + assertEquals(2L, today["s.starts"]) + } + + @Test + fun repeatedActivationsDoNotDoubleCountStarts() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", "s.starts", nowMs = { clock }) + + session.setActive(true) + clock += 1_000 + session.setActive(true) + clock += 1_000 + session.setActive(false) + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(2_000L, today["s.ms"]) + assertEquals(1L, today["s.starts"]) + } + + @Test + fun openSegmentIsAccountedOnFlushWithoutClosing() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val session = SessionTimeIntegrator(accountant, "s.ms", nowMs = { clock }) + session.registerFlushHook() + + session.setActive(true) + clock += 120_000 + val mid = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals("multi-hour stable sessions account without a transition", 120_000L, mid["s.ms"]) + + clock += 30_000 + session.setActive(false) + val end = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(150_000L, end["s.ms"]) + } +} + +class BatteryDrainSamplerTest { + @get:Rule + val temp = TemporaryFolder() + + private fun harness(scope: CoroutineScope): Triple { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, scope, epochDay = { 100L }) + val controls = Controls() + val sampler = + BatteryDrainSampler( + accountant = accountant, + capacityPct = { controls.pct }, + isCharging = { controls.charging }, + isForeground = { controls.foreground }, + ) + return Triple(accountant, sampler, controls) + } + + private class Controls { + var pct: Int? = 90 + var charging = false + var foreground = true + } + + @Test + fun firstSampleOnlyEstablishesTheBaseline() = + runTest { + val (accountant, sampler, _) = harness(backgroundScope) + sampler.sample() + assertNull(accountant.allDaysIncludingLive()[100]?.get(UsageKeys.BATTERY_DRAIN_FG)) + } + + @Test + fun dischargeDropsAreAccountedByVisibility() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90, discharging + controls.pct = 87 + sampler.sample() // -3 while foreground + controls.foreground = false + controls.pct = 85 + sampler.sample() // -2 while background + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(3L, today[UsageKeys.BATTERY_DRAIN_FG]) + assertEquals(2L, today[UsageKeys.BATTERY_DRAIN_BG]) + } + + @Test + fun intervalsTouchingAChargerAreSkipped() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90, discharging + controls.charging = true + controls.pct = 80 // weird drop while charging: ignore + sampler.sample() + controls.charging = false + controls.pct = 78 // first discharging interval after charging: baseline only + sampler.sample() + controls.pct = 77 + sampler.sample() // clean discharging interval: -1 + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG]) + } + + @Test + fun aLevelIncreaseResetsTheBaselineWithoutAccounting() = + runTest { + val (accountant, sampler, controls) = harness(backgroundScope) + sampler.sample() // baseline 90 + controls.pct = 95 // e.g. charged while the process was frozen + sampler.sample() + controls.pct = 94 + sampler.sample() // -1 + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(1L, today[UsageKeys.BATTERY_DRAIN_FG]) + } +} + +class MeteringNostrSignerTest { + @get:Rule + val temp = TemporaryFolder() + + /** Pure-Kotlin fake so the test never touches secp256k1 JNI. */ + private class FakeSigner : NostrSigner("aa".repeat(32)) { + override fun isWriteable() = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = throw UnsupportedOperationException("fake") + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "enc04" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "dec04" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "enc44" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "dec44" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw UnsupportedOperationException("fake") + + override suspend fun deriveKey(nonce: HexKey): HexKey = "bb".repeat(32) + + override suspend fun signPsbt(psbtHex: String): String = psbtHex + + override fun hasForegroundSupport() = true + } + + @Test + fun countsSignsAndCryptoOpsWithoutChangingResults() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + val metered = MeteringNostrSigner(FakeSigner(), accountant) + + assertEquals("dec44", metered.nip44Decrypt("x", "aa".repeat(32))) + assertEquals("dec04", metered.nip04Decrypt("x", "aa".repeat(32))) + assertEquals("enc44", metered.nip44Encrypt("x", "aa".repeat(32))) + runCatching { metered.sign(0L, 1, arrayOf(), "hello") } + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(2L, today[UsageKeys.DECRYPT_COUNT]) + assertEquals(1L, today[UsageKeys.ENCRYPT_COUNT]) + assertEquals(1L, today[UsageKeys.signs(UsageKeys.SIGNER_LOCAL)]) + assertNull("non-local signers must not pollute crypto CPU time", today[UsageKeys.DECRYPT_US]) + } + + @Test + fun innermostSignerUnwrapsTheMeteringDecorator() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + val raw = FakeSigner() + val metered = MeteringNostrSigner(raw, accountant) + assertEquals(raw, metered.innermostSigner()) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From ed04c373b81cb7e848ccbe5c97046d4eb0681ba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:56:51 +0000 Subject: [PATCH 113/206] fix(ci): serialize :amethyst Kotlin compilations to stop daemon OOMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs die with java.lang.OutOfMemoryError (GC overhead limit exceeded / Java heap space) inside the shared Kotlin compile daemon when two variant compilations of :amethyst run at the same time — e.g. compilePlayDebugKotlin and compilePlayBenchmarkKotlin in the test-and-build-android job, which demands four :amethyst variants (unit tests, lint, assembleBenchmark) in one invocation. KotlinCompile submits its work asynchronously via the Worker API, so the project lock is released while compilation is still running in the daemon and Gradle happily starts the next variant's compile task in parallel; two full :amethyst codegen passes no longer fit the daemon's -Xmx8g heap (see runs 29260711359 and 29248908870; removing Kover in #3539 helped but was not sufficient). Register a no-op shared build service with maxParallelUsages = 1 and make every :amethyst KotlinCompile task use it. The scheduler then runs this module's Kotlin compilations one at a time — a task holding the service is not complete until its async worker finishes — while all other work (other modules' compilation, JVM tests, lint analysis, packaging) keeps running in parallel. Verified with Worker-API probe tasks: unconstrained same-project tasks start simultaneously; with usesService they run strictly one after the other. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ifbUGCADckUb3zaox9wBo --- amethyst/build.gradle.kts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index e4df9eb8bf..730f044c4e 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -1,4 +1,7 @@ +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.androidApplication) @@ -342,6 +345,25 @@ kotlin { } } +// Gradle schedules Kotlin compilations of different variants of this module +// concurrently (e.g. playDebug + playBenchmark when CI runs unit tests, lint, +// and assembleBenchmark in one invocation), but they all share a single Kotlin +// daemon whose heap (kotlin.daemon.jvmargs) cannot fit two full :amethyst +// codegen passes — CI runs died with "GC overhead limit exceeded" inside the +// daemon. This no-op shared build service with maxParallelUsages = 1 tells the +// scheduler to run this module's Kotlin compile tasks one at a time; other +// projects' tasks (JVM tests, lint analysis, packaging) still run in parallel. +abstract class AmethystKotlinCompileLimiter : BuildService + +val kotlinCompileLimiter = + gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) { + maxParallelUsages.set(1) + } + +tasks.withType().configureEach { + usesService(kotlinCompileLimiter) +} + composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_compiler") metricsDestination = layout.buildDirectory.dir("compose_compiler") From 1d385cae0f01c131cbb5befc7fc3d64c82214446 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:04:36 +0000 Subject: [PATCH 114/206] refactor(ui): move HeaderPill and QuietMark to commons/ui/note The two note-header marker primitives are pure Compose with no Android dependencies, so they belong in the shared layer where the desktop app (and future iOS) can render the same design system. QuietMark's gray comes from a new commons ColorScheme.placeholderText extension (onSurface at 42%, matching the Android theme's value). The thin app-side wrappers (BoostedMark, DisplayPoW, DisplayDraft, DisplayOts, DisplayLocation, DisplayReward, ...) stay in amethyst: they bind the primitives to Android string resources (with existing translations that commons' compose-resources catalog does not have) and to app state (LocalCache loaders, AccountViewModel, navigation). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 4 ++-- .../amethyst/ui/note/elements/BoostedMark.kt | 1 + .../amethyst/ui/note/elements/DisplayEditStatus.kt | 1 + .../amethyst/ui/note/elements/DisplayLocation.kt | 1 + .../amethyst/ui/note/elements/DisplayOts.kt | 1 + .../amethyst/ui/note/elements/DisplayPoW.kt | 1 + .../amethyst/ui/note/elements/DisplayReward.kt | 1 + .../ui/note/elements/NoteHeaderMarkersPreview.kt | 3 ++- .../loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 2 +- .../amethyst/commons/ui/note}/HeaderPill.kt | 2 +- .../amethyst/commons/ui/note}/QuietMark.kt | 12 +++++++----- .../amethyst/commons/ui/theme/ThemeExtensions.kt | 8 ++++++++ 12 files changed, 27 insertions(+), 10 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note}/HeaderPill.kt (98%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note}/QuietMark.kt (91%) 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 39ef85a00c..579844d990 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 @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache @@ -84,9 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayLocation import com.vitorpamplona.amethyst.ui.note.elements.DisplayOts import com.vitorpamplona.amethyst.ui.note.elements.DisplayPoW import com.vitorpamplona.amethyst.ui.note.elements.DisplayReward -import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton -import com.vitorpamplona.amethyst.ui.note.elements.QuietMark import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt index 7b81088adc..c48531aaca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.stringRes @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 48a2e6b60f..0ffae65a1d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index cf240cb7f2..a32e65d1b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index c5cf453138..319293d69c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.LoadOts import com.vitorpamplona.amethyst.ui.note.timeAgoNoDot diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index 64aad9648a..e6fc7b772e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index 0b36780bd1..fbd4270e07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -55,6 +55,7 @@ import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReplies diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index ea1c4287df..02438cf59a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -76,7 +76,8 @@ private val COMMUNITY_ADDRESS = "34550:" + "c".repeat(64) + ":amethyst-users" * Design-system preview for the note-header first row, rendered by the REAL * `FirstUserInfoRow` over notes seeded into [LocalCache] — the same pattern * the ZapPollNote/Poll previews use. Rows go from bare to fully loaded so the - * two marker tiers ([HeaderPill] and [QuietMark]) can be reviewed together. + * two marker tiers (`HeaderPill` and `QuietMark`, now in `commons/ui/note`) + * can be reviewed together. * * Two markers cannot appear in a static preview because their visibility is * computed inside effects that previews never run: the followed-hashtag label 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 976cbe3032..52aec3f3c0 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 @@ -52,6 +52,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull import com.vitorpamplona.amethyst.ui.note.LoadPublicChatChannel import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent -import com.vitorpamplona.amethyst.ui.note.elements.HeaderPill import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.elements.ToggleableTimeAgoText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 6661391c8b..6c4dc4726b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -18,7 +18,7 @@ * 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.note.elements +package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt similarity index 91% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt index acd339471e..8f3cea79a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/QuietMark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt @@ -18,7 +18,7 @@ * 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.note.elements +package com.vitorpamplona.amethyst.commons.ui.note import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -31,10 +31,12 @@ 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.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText + +private val QuietMarkFontSize = 12.sp /** * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, @@ -70,8 +72,8 @@ fun QuietMark( Text( text = text, color = MaterialTheme.colorScheme.placeholderText, - fontSize = Font12SP, - lineHeight = Font12SP, + fontSize = QuietMarkFontSize, + lineHeight = QuietMarkFontSize, fontWeight = FontWeight.Medium, maxLines = 1, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt index 0e8e0866fd..ad4488e405 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ThemeExtensions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.ui.theme import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.luminance @@ -36,3 +37,10 @@ val ColorScheme.isLight: Boolean */ val ColorScheme.onBackgroundColorFilter: ColorFilter get() = ColorFilter.tint(onBackground) + +/** + * De-emphasized text/icon color for secondary markers and hints. Matches the + * Android app's placeholderText, which is the palette's onSurface at 42%. + */ +val ColorScheme.placeholderText: Color + get() = onSurface.copy(alpha = 0.42f) From bd4d80160e27882f6323400f3995efc61247a2be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:14:41 +0000 Subject: [PATCH 115/206] fix: resolve remaining audit findings in worker gates, ledger accuracy, and alert flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScheduledPostWorkGate now treats PUBLISHING as pending work: claiming the last PENDING post no longer cancels the periodic worker mid-publish (which stranded the post in PUBLISHING with no recovery). ScheduledPostStore also releases stale PUBLISHING claims at initial load — a claim found on disk belongs to a dead process and is reset to PENDING for retry. - CalendarReminderWorker decides its self-cancel on a FRESH cache snapshot, closing the race where an RSVP accepted mid-run was lost forever (the observer's KEEP schedule no-ops while the chain exists). A second observer on kind-31922/31923 re-arms the chain when the organizer reschedules the target event — the existing RSVP never re-fires observeNewEvents, so the moved appointment previously produced no reminder. The rescan is throttled (conflate + 30s) and gated on the feature being enabled. - UsageCountingInterceptor skips loopback hosts: local Blossom cache hits (127.0.0.1) never touch the radio and were inflating mobile rx bytes, radio-burst estimates, and potentially the background-data alert. - The usage alert's 7-day rate limit is now consumed when the user acts on the dialog (any button or dismissal) instead of before the first frame, so a config change or process death no longer burns the prompt budget on a dialog nobody saw. - DEV_REPORT_PUBKEY is defined once in service/crashreports and imported by both the crash and resource-usage send paths. - ResourceUsageScreen remembers its summaries keyed on the loaded data — the 2s memory ticker was recomputing the full multi-day counter walk on every recomposition. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../com/vitorpamplona/amethyst/AppModules.kt | 28 ++++++++++++++++++ .../calendar/CalendarReminderWorker.kt | 15 +++++++--- .../service/crashreports/DevReportContact.kt | 28 ++++++++++++++++++ .../crashreports/DisplayCrashMessages.kt | 2 +- .../DisplayResourceUsageAlert.kt | 29 +++++++++++-------- .../resourceusage/UsageCountingInterceptor.kt | 12 ++++++++ .../scheduledposts/ScheduledPostStore.kt | 21 +++++++++++++- .../scheduledposts/ScheduledPostWorkGate.kt | 7 ++++- .../loggedIn/settings/ResourceUsageScreen.kt | 11 ++++--- .../resourceusage/ResourceUsageLedgerTest.kt | 13 +++++++++ .../scheduledposts/ScheduledPostStoreTest.kt | 16 ++++++++++ .../ScheduledPostWorkGateTest.kt | 22 ++++++++++++++ 12 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index b36593c4aa..c0974104f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.CachedRichTextParser +import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.amethyst.service.cast.CastRegistry import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager @@ -116,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.amethyst.ui.tor.TorService import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger @@ -143,12 +145,15 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClie import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -161,6 +166,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull @@ -1063,6 +1069,28 @@ class AppModules( } } + // A rescheduled appointment must also re-arm the chain: the worker + // cancels itself when every known target is in the past, and a + // kind-31922/31923 update (the organizer moving the event) arrives + // WITHOUT any new RSVP — the user's existing RSVP still points at the + // same address, and observeNewEvents never re-fires for it. conflate + + // delay bounds the cache rescan to one per 30s while event feeds + // stream slot events; the scan only runs for users with reminders on. + applicationIOScope.launch { + LocalCache + .observeNewEvents( + Filter(kinds = listOf(CalendarDateSlotEvent.KIND, CalendarTimeSlotEvent.KIND)), + ).conflate() + .collect { + if (CalendarReminderPrefs(appContext).isEnabled() && + CalendarReminderWorker.couldStillFire(CalendarReminderWorker.acceptedRsvpsInCache(), TimeUtils.now()) + ) { + CalendarReminderWorker.schedule(appContext) + } + delay(30_000) + } + } + // Watch for account login and start/stop always-on notification service applicationIOScope.launch { sessionManager.accountContent.collectLatest { state -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt index 60453031bd..fb65cb09fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/calendar/CalendarReminderWorker.kt @@ -120,10 +120,17 @@ class CalendarReminderWorker( store.forgetBefore(now - PRUNE_AGE_SECONDS) // Nothing left that could ever fire → end the periodic chain instead of - // waking the process every 15 minutes forever. The ACCEPTED-RSVP observer - // in AppModules re-schedules the worker the next time a live session sees - // an accepted RSVP. - if (!couldStillFire(acceptedRsvps, now)) { + // waking the process every 15 minutes forever. The observers in + // AppModules re-schedule the worker the next time a live session sees + // an accepted RSVP or a calendar-event update. + // + // Decide on a FRESH cache snapshot, not the one from the start of the + // run: an RSVP accepted while this run was scanning already fired the + // observer, whose schedule() uses KEEP and no-ops while this chain + // still exists — cancelling on the stale snapshot would kill the chain + // with that RSVP's reminder permanently lost (observeNewEvents never + // re-fires for an event that is already in cache). + if (!couldStillFire(acceptedRsvpsInCache(), TimeUtils.now())) { Log.d(TAG) { "No accepted RSVP can still fire; ending periodic chain." } cancel(applicationContext) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt new file mode 100644 index 0000000000..8cf5d02441 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DevReportContact.kt @@ -0,0 +1,28 @@ +/* + * 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.service.crashreports + +/** + * Developer recipient for every user-initiated diagnostic NIP-17 DM — crash + * reports and resource-usage reports both route here. Single definition so a + * key rotation can never leave one path DMing the old key. + */ +const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt index 073b4a49f0..b5fcfc4b83 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/crashreports/DisplayCrashMessages.kt @@ -80,7 +80,7 @@ fun DisplayCrashMessages( onClick = { nav.nav { routeToMessage( - user = LocalCache.getOrCreateUser("aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b"), + user = LocalCache.getOrCreateUser(DEV_REPORT_PUBKEY), draftMessage = stack, accountViewModel = accountViewModel, expiresDays = 30, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt index ddac466f85..733f06f392 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/DisplayResourceUsageAlert.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,12 +44,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -/** - * Developer recipient for user-initiated diagnostic DMs — the same account - * the crash-report dialog routes to. - */ -const val DEV_REPORT_PUBKEY = "aa9047325603dacd4f8142093567973566de3b1e20a89557b728c3be4c6a844b" - /** * On app open, checks the resource-usage ledger against the * [ResourceUsageAlerts] thresholds and — at most once per week, unless the @@ -74,16 +69,26 @@ fun DisplayResourceUsageAlert( } val found = ResourceUsageAlerts.evaluate(accountant.allDaysIncludingLive(), accountant.today()) if (found != null) { - // Mark immediately so process restarts can't re-prompt within the window. - store.markAlertPrompted(TimeUtils.now()) alert.value = found } } } + // The 7-day rate limit is consumed when the user ACTS on the dialog (any + // button or dismissal), not when it is shown: marking before the first + // frame let a config change or process death burn the whole prompt budget + // on a dialog nobody ever saw, silencing an active battery problem for a + // week. Re-showing after an unhandled recreation is exactly what we want. + val dismiss = { + accountViewModel.runOnIO { + Amethyst.instance.resourceUsageStore.markAlertPrompted(TimeUtils.now()) + } + alert.value = null + } + alert.value?.let { found -> AlertDialog( - onDismissRequest = { alert.value = null }, + onDismissRequest = dismiss, title = { Text(stringRes(R.string.resource_usage_alert_title)) }, text = { Text( @@ -99,11 +104,11 @@ fun DisplayResourceUsageAlert( accountViewModel.runOnIO { Amethyst.instance.resourceUsageStore.setAlertsOptOut(true) } - alert.value = null + dismiss() }) { Text(stringRes(R.string.resource_usage_alert_opt_out)) } - TextButton(onClick = { alert.value = null }) { + TextButton(onClick = dismiss) { Text(stringRes(R.string.resource_usage_alert_not_now)) } } @@ -126,7 +131,7 @@ fun DisplayResourceUsageAlert( expiresDays = 30, ) } - alert.value = null + dismiss() }) { Text(stringRes(R.string.resource_usage_alert_send)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt index 3931d3d8e0..83e8cafe21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageCountingInterceptor.kt @@ -96,6 +96,13 @@ class UsageCountingInterceptor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() + + // Loopback traffic (LocalBlossomCacheRedirectInterceptor rewrites cache + // hits to 127.0.0.1) never touches the radio: counting it would inflate + // the network numbers — and could trip the background-data alert — for + // exactly the users who cache aggressively to SAVE data. + if (isLoopback(request.url.host)) return chain.proceed(request) + val role = request.tag(UsageRoleTag::class.java)?.role ?: defaultRole accountant.add(UsageKeys.netReqs(role, isMobile(), isForeground()), 1) @@ -124,6 +131,11 @@ class UsageCountingInterceptor( ).build() } + companion object { + /** OkHttp reports IPv6 hosts unbracketed ("::1"); keep the bracketed form defensively. */ + fun isLoopback(host: String): Boolean = host.startsWith("127.") || host == "localhost" || host == "::1" || host == "[::1]" + } + private class CountingResponseBody( private val delegate: ResponseBody, private val onBytes: (Long) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt index d5898b181b..7d86510ed3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStore.kt @@ -206,9 +206,28 @@ class ScheduledPostStore( mutableListOf() } loaded = true + val recovered = releaseStaleClaims() val purged = purgeStale(nowSec()) _flow.value = posts.toList() - if (purged) persist() + if (purged || recovered) persist() + } + + /** + * A PUBLISHING row found at initial disk load is a claim from a previous + * process — the worker that held it died (crash, cancellation) before + * markSent/markFailed/releaseClaim ran. No worker in THIS process can + * hold a claim before the first load, so reset them to PENDING for the + * next cycle to retry. Returns true if any row was recovered. + */ + private fun releaseStaleClaims(): Boolean { + var recovered = false + posts.forEachIndexed { idx, post -> + if (post.status == ScheduledPostStatus.PUBLISHING) { + posts[idx] = post.copy(status = ScheduledPostStatus.PENDING) + recovered = true + } + } + return recovered } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt index 3cda10674b..dd0542050a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGate.kt @@ -37,6 +37,11 @@ import kotlinx.coroutines.launch * publishNow, releaseClaim) and [onNoPendingWork] when the last one drains * (markSent/markFailed/cancel/removeForAccount). * + * PUBLISHING counts as pending work: claimDuePosts flips PENDING → PUBLISHING + * (and emits) BEFORE the worker publishes, so gating on PENDING alone would + * cancel the periodic worker mid-publish the moment it claims the last post — + * stranding the post in PUBLISHING with nothing left to finish or retry it. + * * [start] forces the store's initial disk load BEFORE collecting: the flow's * initial value is an empty list until the store is first touched, and acting * on that placeholder would cancel scheduled work that a pending post still @@ -52,7 +57,7 @@ class ScheduledPostWorkGate( scope.launch { store.list() store.flow - .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING } } + .map { posts -> posts.any { it.status == ScheduledPostStatus.PENDING || it.status == ScheduledPostStatus.PUBLISHING } } .distinctUntilChanged() .collect { hasPending -> if (hasPending) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index aac8e93e84..d76c0a1ae3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -58,7 +58,7 @@ import com.vitorpamplona.amethyst.MemorySnapshot import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.service.resourceusage.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours @@ -125,9 +125,12 @@ fun ResourceUsageScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - val today = Amethyst.instance.resourceUsage.today() - val todaySummary = UsageSummary.from(loaded[today].orEmpty()) - val weekSummary = UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) + // remember(loaded): the memory produceState recomposes this + // scope every 2s, and the summaries walk every counter of + // every retained day — recompute only when the data reloads. + val today = remember(loaded) { Amethyst.instance.resourceUsage.today() } + val todaySummary = remember(loaded) { UsageSummary.from(loaded[today].orEmpty()) } + val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) } TodayTiles(todaySummary) if (weekSummary.totalBytes > 0) { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 3a09f2064f..6a2e216392 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -363,6 +363,19 @@ class RadioBurstEstimatorTest { } } +class LoopbackExclusionTest { + @Test + fun loopbackHostsAreRecognizedAndRealHostsAreNot() { + assertTrue(UsageCountingInterceptor.isLoopback("127.0.0.1")) + assertTrue(UsageCountingInterceptor.isLoopback("127.4.5.6")) + assertTrue(UsageCountingInterceptor.isLoopback("localhost")) + assertTrue(UsageCountingInterceptor.isLoopback("::1")) + assertFalse(UsageCountingInterceptor.isLoopback("relay.example.com")) + assertFalse(UsageCountingInterceptor.isLoopback("192.168.1.10")) + assertFalse(UsageCountingInterceptor.isLoopback("128.0.0.1")) + } +} + class MediaPlayTimeTrackerTest { @Test fun accumulatesOnlyWhilePlaying() { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt index ea6e0a09a3..4389ea6b78 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostStoreTest.kt @@ -60,6 +60,22 @@ class ScheduledPostStoreTest { createdAtSec = 500, ) + @Test + fun stalePublishingClaimsAreReleasedOnLoad() = + runTest { + val store = newStore() + store.add(samplePost(id = "a")) + val claimed = store.claimDuePosts(nowSec = 2_000) + assertEquals(1, claimed.size) + assertEquals(ScheduledPostStatus.PUBLISHING, store.list().single().status) + + // A fresh store (new process) finds the on-disk PUBLISHING row: the + // worker that claimed it died with the old process, so the claim + // must be released for the next cycle to retry. + val reloaded = newStore().list().single() + assertEquals(ScheduledPostStatus.PENDING, reloaded.status) + } + @Test fun add_persists_to_disk() = runTest { diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt index f3577d52f1..85f85d0515 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/scheduledposts/ScheduledPostWorkGateTest.kt @@ -130,6 +130,28 @@ class ScheduledPostWorkGateTest { assertEquals(listOf(false, true, false, true), decisions) } + @Test + fun claimingTheLastPendingPost_doesNotCancelTheRunningWorker() = + runTest { + val store = newStore() + store.add(samplePost(id = "a", publishAtSec = 1_000)) + startGate(store) + runCurrent() + assertEquals(listOf(true), decisions) + + // The periodic worker claims the post (PENDING -> PUBLISHING) and + // is now mid-publish. Cancelling here would kill the very worker + // holding the claim and strand the post in PUBLISHING forever. + store.claimDuePosts(nowSec = 2_000) + runCurrent() + assertEquals("a claim must never cancel the chain", listOf(true), decisions) + + // Only the publish actually finishing drains the gate. + store.markSent("a") + runCurrent() + assertEquals(listOf(true, false), decisions) + } + @Test fun publishNowOnFailedPost_reschedulesTheWorker() = runTest { From b2914fbc4bf6afc8925a6dcae4e15f226368a9b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:21:20 +0000 Subject: [PATCH 116/206] feat: count always-on notification service starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uptime alone can't distinguish a stable 24h service from one the watchdog restarts every hour — each restart is a process wake plus a full round of relay reconnects. Reported alongside uptime in the usage report. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../src/main/java/com/vitorpamplona/amethyst/AppModules.kt | 2 +- .../service/resourceusage/ResourceUsageReportAssembler.kt | 2 +- .../amethyst/service/resourceusage/UsageKeys.kt | 7 ++++++- .../amethyst/service/resourceusage/UsageSummary.kt | 2 ++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c0974104f2..04f711baba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -338,7 +338,7 @@ class AppModules( // Session-time counters for the app's long-running battery consumers. // All timer-free segment integrators: services and status flows flip them // on/off, so tracking costs one counter write per transition. - val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS).also { it.registerFlushHook() } + val alwaysOnSession = SessionTimeIntegrator(resourceUsage, UsageKeys.ALWAYS_ON_MS, UsageKeys.ALWAYS_ON_STARTS).also { it.registerFlushHook() } val callSession = SessionTimeIntegrator(resourceUsage, UsageKeys.CALL_MS, UsageKeys.CALL_SESSIONS).also { it.registerFlushHook() } val nestsSession = SessionTimeIntegrator(resourceUsage, UsageKeys.NESTS_MS, UsageKeys.NESTS_SESSIONS).also { it.registerFlushHook() } private val powSession = SessionTimeIntegrator(resourceUsage, UsageKeys.POW_MS, UsageKeys.POW_SESSIONS).also { it.registerFlushHook() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 6c224b9f1b..cd23077473 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -100,7 +100,7 @@ class ResourceUsageReportAssembler { append("| Media playback | ${formatDurationMs(s.mediaPlayMs)} |\n") append("| PoW mining | ${formatDurationMs(s.powMs)} |\n") append("| Tor uptime (in-app) | ${formatDurationMs(s.torMs)} |\n") - append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} |\n") + append("| Always-on service | ${formatDurationMs(s.alwaysOnMs)} (${s.alwaysOnStarts} starts) |\n") append("| Calls / audio rooms | ${formatDurationMs(s.callMs)} / ${formatDurationMs(s.nestsMs)} |\n") append("| Location listening | ${formatDurationMs(s.locationMs)} |\n") append("| Signatures verified | ${s.verifyCount} (${formatDurationMs(s.verifyUs / 1_000)} CPU) |\n") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 0f8a93aa4f..357dfd644d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -135,8 +135,13 @@ object UsageKeys { const val TOR_MS = "tor.ms" const val TOR_STARTS = "tor.starts" - /** Always-on notification relay service uptime — the mode context for its relay connections. */ + /** + * Always-on notification relay service: uptime (the mode context for its + * relay connections) and starts — each start beyond the first is churn + * (watchdog alarm or auto-restart re-launching a killed service). + */ const val ALWAYS_ON_MS = "service.alwayson.ms" + const val ALWAYS_ON_STARTS = "service.alwayson.starts" /** Calls and NIP-53 audio rooms: mic + Opus + a live media connection. */ const val CALL_MS = "call.ms" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index 72cb38117c..f28f851efa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -53,6 +53,7 @@ data class UsageSummary( val powMs: Long, val torMs: Long, val alwaysOnMs: Long, + val alwaysOnStarts: Long, val callMs: Long, val nestsMs: Long, val locationMs: Long, @@ -110,6 +111,7 @@ data class UsageSummary( powMs = counters[UsageKeys.POW_MS] ?: 0L, torMs = counters[UsageKeys.TOR_MS] ?: 0L, alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, + alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L, callMs = counters[UsageKeys.CALL_MS] ?: 0L, nestsMs = counters[UsageKeys.NESTS_MS] ?: 0L, locationMs = counters[UsageKeys.LOCATION_MS] ?: 0L, From 53e8f8df09413b5893f2662dbf7d9ebd49df42e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 16:30:07 +0000 Subject: [PATCH 117/206] feat: always-on service cost card with a link to notification settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The uptime row told users the service ran, not what it cost — and the setting to turn it off lives three screens away. The resource screen now shows a dedicated card (only while the service has run this week) that frames the cost in decision terms: running time and restarts, the relay connections it held while the app was closed, and the measured background battery drain — with a button straight to notification settings where the delivery method can be changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../loggedIn/settings/ResourceUsageScreen.kt | 57 +++++++++++++++++-- amethyst/src/main/res/values/strings.xml | 11 +++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index d76c0a1ae3..2b972a6d14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -51,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst @@ -65,6 +67,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -140,6 +143,7 @@ fun ResourceUsageScreen( } SubsystemSection(weekSummary) ActivitySection(weekSummary) + AlwaysOnServiceSection(weekSummary, nav) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -310,10 +314,6 @@ private fun ActivitySection(s: UsageSummary) { SettingsDivider() MetricRow(R.string.resource_usage_tor, formatDurationMs(s.torMs)) } - if (s.alwaysOnMs > 0) { - SettingsDivider() - MetricRow(R.string.resource_usage_always_on, formatDurationMs(s.alwaysOnMs)) - } if (s.callMs > 0) { SettingsDivider() MetricRow(R.string.resource_usage_calls, formatDurationMs(s.callMs)) @@ -400,6 +400,55 @@ private fun MemorySection(memory: MemorySnapshot?) { } } +/** + * Decision-support card for the always-on notification service — the one + * background consumer users can directly turn off. Shows what the service + * actually costs in the last 7 days (uptime, the relay connections it holds + * while the app is closed, and the measured background battery drain) and + * links straight to the setting that controls it. + */ +@Composable +private fun AlwaysOnServiceSection( + s: UsageSummary, + nav: INav, +) { + if (s.alwaysOnMs <= 0) return + val starts = s.alwaysOnStarts.toInt() + SettingsSection(R.string.resource_usage_alwayson_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_alwayson_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MetricRow( + R.string.resource_usage_alwayson_uptime, + "${formatDurationMs(s.alwaysOnMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}", + ) + SettingsDivider() + MetricRow( + R.string.resource_usage_alwayson_bg_relay, + formatConnHours(s.relayConnMsMobileBg + s.relayConnMsWifiBg), + ) + if (s.batteryDrainBg > 0) { + SettingsDivider() + MetricRow(R.string.resource_usage_alwayson_battery, "${s.batteryDrainBg}%") + } + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + TextButton( + onClick = { nav.nav(Route.NotificationSettings) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_alwayson_settings_button)) + } + } + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e3e5e34e8e..df93f3236c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3213,11 +3213,20 @@ Remote signatures Proof-of-work mining Tor uptime - Always-on notification service Time in calls Time in audio rooms Location listening Battery used (in app / background) + Background notification service + Keeps connections to your relays open while the app is closed, so notifications arrive without Google’s push servers. While enabled, this is usually the largest background battery cost. If battery matters more to you than instant notifications, you can change the delivery method or turn it off. + Running time + + %1$d start + %1$d starts + + Relay connections held while closed + Battery drained meanwhile (whole device) + Change notification settings Data by feature (7 days) Relay sync Images From 824dee6fb691cb36cefcaaee43ea941182306da2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 17:25:42 +0000 Subject: [PATCH 118/206] 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 899ada61e47b5377c4bf896e2b72c2f10555546b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 18:00:28 +0000 Subject: [PATCH 119/206] fix(ci): apply the :amethyst Kotlin compile limiter only on CI The daemon OOM needs a cache-cold compile of several :amethyst variants in one invocation, which only CI's combined test/lint/assembleBenchmark run produces; local builds are incremental and usually build a single variant, and have never shown the problem. Gate the maxParallelUsages=1 service on the CI env var (always set on GitHub Actions) so local multi-variant builds keep their parallelism. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018ifbUGCADckUb3zaox9wBo --- amethyst/build.gradle.kts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 730f044c4e..63cf319a46 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -353,15 +353,20 @@ kotlin { // daemon. This no-op shared build service with maxParallelUsages = 1 tells the // scheduler to run this module's Kotlin compile tasks one at a time; other // projects' tasks (JVM tests, lint analysis, packaging) still run in parallel. +// +// CI-only: the OOM needs a cache-cold compile of several variants at once, +// which local builds (incremental, usually one variant) don't produce. abstract class AmethystKotlinCompileLimiter : BuildService -val kotlinCompileLimiter = - gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) { - maxParallelUsages.set(1) - } +if (System.getenv("CI") != null) { + val kotlinCompileLimiter = + gradle.sharedServices.registerIfAbsent("amethystKotlinCompileLimiter", AmethystKotlinCompileLimiter::class) { + maxParallelUsages.set(1) + } -tasks.withType().configureEach { - usesService(kotlinCompileLimiter) + tasks.withType().configureEach { + usesService(kotlinCompileLimiter) + } } composeCompiler { From 0e485349e3a75943f7f0f3df66a0d34cd7b432ba Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:03:37 -0400 Subject: [PATCH 120/206] 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 121/206] 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 122/206] 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 8a98ed3d15c037828a4f244b96f6d2850bbbc062 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 18:07:59 +0000 Subject: [PATCH 123/206] feat: actionable usage insights, rates, cellular-first bars, Tor card, and per-screen time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen presented evidence and left the user to be the analyst; now it draws the conclusions and links each one to the setting that acts on it: - Insights: a UsageInsights rules layer (unit-tested, thresholds informed by the ping study and normalized per observed day) renders up to three plain-language recommendations, each deep-linking to notification settings, media settings, the relay list, or privacy options. - Rates: battery %/hour in app (from the measured drain sampler), data/hour in app, and average simultaneous relay connections — totals aren't judgeable, rates are, and avg relays is directly actionable. - Subsystem bars rank by CELLULAR bytes when any exist (with totals as secondary context) — Wi-Fi bytes are nearly free, so ranking by totals pointed users at the wrong feature. - Built-in Tor gets the same cost card as the always-on service: uptime, bootstraps, and a link to privacy options. - Per-screen time (screen..ms): a ScreenTimeIntegrator fed by the nav controller's destination listener, gated on foreground. PRIVACY: only the route's base name is recorded — screenNameOf strips every navigation argument before the value leaves the navigation layer, so the ledger says 'Profile', never whose profile. Shown as proportion bars and included in the report's summary table. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../plans/2026-07-12-resource-usage-ledger.md | 14 +- .../com/vitorpamplona/amethyst/AppModules.kt | 10 + .../ResourceUsageReportAssembler.kt | 8 + .../resourceusage/ScreenTimeIntegrator.kt | 85 ++++++++ .../service/resourceusage/UsageInsights.kt | 98 +++++++++ .../service/resourceusage/UsageKeys.kt | 10 + .../service/resourceusage/UsageSummary.kt | 35 +++- .../amethyst/ui/navigation/AppNavigation.kt | 25 +++ .../loggedIn/settings/ResourceUsageScreen.kt | 192 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 16 ++ .../resourceusage/ResourceUsageLedgerTest.kt | 158 ++++++++++++++ 11 files changed, 642 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt diff --git a/amethyst/plans/2026-07-12-resource-usage-ledger.md b/amethyst/plans/2026-07-12-resource-usage-ledger.md index 9c7e641739..eecf3161a1 100644 --- a/amethyst/plans/2026-07-12-resource-usage-ledger.md +++ b/amethyst/plans/2026-07-12-resource-usage-ledger.md @@ -106,10 +106,16 @@ Flat `Map` per UTC epoch-day, retained ~30 days. Key grammar: at flush from BatteryManager: NOT app-isolated, but the ground truth that report corpora can correlate the other counters against -Deliberately not tracked (v1): per-screen time (route names leak behavior -patterns into a report — needs its own privacy review), per-coroutine or -per-dispatcher CPU (needs a thread registry; `cpu.ms` answers whether CPU -matters at all first), signing (user-action-rate, negligible). +- `screen..ms` — foreground time per screen, added after the original + privacy review: only the route's base NAME is recorded (screenNameOf strips + every navigation argument before the value leaves the nav layer), so the + ledger can say "Profile" but never whose profile + +Deliberately not tracked (v1): per-coroutine or per-dispatcher CPU (needs a +thread registry; `cpu.ms` answers whether CPU matters at all first). +(Two earlier v1 exclusions were later revisited: per-screen time ships with +names-only privacy as above, and signing is now counted per signer kind +because NIP-46/NIP-55 signatures are network/IPC round-trips, not local CPU.) Flat keys keep the store schema-free: new counters need no migration. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 04f711baba..d8cba9a64b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeInteg import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.SessionTimeIntegrator import com.vitorpamplona.amethyst.service.resourceusage.UsageCountingInterceptor import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys @@ -345,6 +346,15 @@ class AppModules( private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() } private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() } + // Time-per-screen (route base names only — arguments never reach the + // ledger). Fed by the navigation listener in AppNavigation; foreground + // gating means backgrounding on a screen closes its segment. + val screenTime = ScreenTimeIntegrator(resourceUsage) + + init { + screenTime.start(applicationIOScope, foregroundTracker.isForeground) + } + // In-app (Arti) Tor uptime. Watches the raw TorService status — NOT // TorManager.status, whose upstream is WhileSubscribed and calls // service.start() when collected, so a permanent ledger subscription diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index cd23077473..e26eb98678 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -118,6 +118,14 @@ class ResourceUsageReportAssembler { if (subsystems.isNotEmpty()) { append("| By subsystem | $subsystems |\n") } + val screens = + s.screenTimeMs.entries + .sortedByDescending { it.value } + .take(8) + .joinToString(", ") { "${it.key} ${formatDurationMs(it.value)}" } + if (screens.isNotEmpty()) { + append("| Screen time | $screens |\n") + } } companion object { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt new file mode 100644 index 0000000000..b995054ace --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ScreenTimeIntegrator.kt @@ -0,0 +1,85 @@ +/* + * 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.service.resourceusage + +import android.os.SystemClock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Integrates time-per-screen into the ledger (`screen..ms`): which + * parts of the app the display/CPU time actually goes to, so a report can + * distinguish "8 h of video" from "8 h of feeds". + * + * PRIVACY: only the route's base name is recorded ([screenNameOf] strips + * navigation arguments before anything reaches the ledger) — "Profile" is + * tracked, whose profile never is. Time only accrues while the app is in the + * foreground: the current-route × foreground combination is the segment + * state, so backgrounding on a screen closes its segment. + */ +class ScreenTimeIntegrator( + accountant: ResourceUsageAccountant, + nowMs: () -> Long = { SystemClock.elapsedRealtime() }, +) : TimeSegmentIntegrator(accountant, nowMs) { + private val currentScreen = MutableStateFlow(null) + + /** Called from the navigation listener with an already-sanitized name (or null when unknown). */ + fun onScreen(name: String?) { + currentScreen.value = name + } + + fun start( + scope: CoroutineScope, + isForeground: Flow, + ): Job { + registerFlushHook() + return scope.launch { + combine(currentScreen, isForeground) { screen, fg -> if (fg) screen else null } + .collect { transitionTo(it) } + } + } + + override fun account( + state: String, + elapsedMs: Long, + ) { + if (elapsedMs > 0) accountant.add(UsageKeys.screenMs(state), elapsedMs) + } + + companion object { + /** + * Reduces a Navigation Compose route pattern to its bare screen name: + * `com...routes.Route.Profile/{userId}?tab={tab}` becomes `Profile`. + * Everything after `/` or `?` — where the arguments live — is dropped + * BEFORE the value leaves the navigation layer. + */ + fun screenNameOf(route: String?): String? = + route + ?.substringBefore('/') + ?.substringBefore('?') + ?.substringAfterLast('.') + ?.takeIf { it.isNotBlank() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt new file mode 100644 index 0000000000..87811e2b30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.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.amethyst.service.resourceusage + +/** + * Turns a week of counters into at most [MAX_INSIGHTS] actionable + * recommendations, each mapping to a setting the user can actually change. + * The numbers alone make the user the analyst; these rules encode the + * analysis (thresholds informed by the 2026-07-12 ping study) and leave the + * user only the decision. + * + * Rules are ordered by typical battery impact; unlike [ResourceUsageAlerts] + * (which detects "something is wrong" and interrupts), insights render + * passively on the usage screen and use much lower thresholds — "worth + * knowing", not "pathological". + */ +object UsageInsights { + /** Each target names the settings surface that can act on the insight. */ + enum class Target { + NOTIFICATION_SETTINGS, + MEDIA_SETTINGS, + RELAY_SETTINGS, + PRIVACY_SETTINGS, + } + + data class Insight( + val target: Target, + /** ms for time-based insights, bytes for data, count for relays. */ + val value: Long, + ) + + /** + * Evaluates a multi-day summary ([UsageSummary.dayCount] normalizes the + * thresholds, so a 2-day-old install is judged on 2 days, not 7). + */ + fun evaluate(s: UsageSummary): List { + val days = s.dayCount.coerceAtLeast(1) + val insights = mutableListOf() + + // Background relay connections dominate drain while the always-on + // service is in use — the one consumer with a dedicated off switch. + val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg + if (s.alwaysOnMs > 0 && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { + insights += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs) + } + + // Cellular media: images/video/previews can be limited to Wi-Fi. + val cellularMediaBytes = + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) + if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { + insights += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes) + } + + // Average simultaneous relay connections across the whole period: + // every open connection is server-pinged every 30-70s, so the radio + // never sleeps while they're up. Fewer relays = less radio time. + val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) + if (avgRelays > AVG_RELAYS) { + insights += Insight(Target.RELAY_SETTINGS, avgRelays) + } + + // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { + insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + } + + return insights.take(MAX_INSIGHTS) + } + + const val MAX_INSIGHTS = 3 + private const val MS_PER_HOUR = 60L * 60L * 1000L + + // Per-day thresholds — deliberately well below the alert levels. + const val BG_RELAY_HOURS_PER_DAY = 3L + const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L + const val AVG_RELAYS = 25L + const val TOR_HOURS_PER_DAY = 4L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index 357dfd644d..e43cde73a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -152,6 +152,16 @@ object UsageKeys { /** Time spent actively listening for GPS/location updates (geohash tagging). */ const val LOCATION_MS = "location.ms" + /** + * `screen.Home.ms` — time a screen was visible while the app was in the + * foreground. PRIVACY: only the route's base NAME is ever recorded, never + * its navigation arguments — "Profile" is tracked, whose profile is not + * (see ScreenTimeIntegrator.screenNameOf, which strips them). + */ + fun screenMs(screen: String): String = "$SCREEN_PREFIX$screen.ms" + + const val SCREEN_PREFIX = "screen." + /** * NIP-04/44 decryptions and encryptions through account signers. Durations * are only metered for local-key signers (CPU cost); external/remote diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt index f28f851efa..1fd24f085d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageSummary.kt @@ -52,6 +52,7 @@ data class UsageSummary( val mediaPlayMs: Long, val powMs: Long, val torMs: Long, + val torStarts: Long, val alwaysOnMs: Long, val alwaysOnStarts: Long, val callMs: Long, @@ -65,25 +66,51 @@ data class UsageSummary( val batteryDrainBg: Long, /** total rx+tx bytes per subsystem (net roles + "relay"). */ val bytesPerSubsystem: Map, + /** cellular-only rx+tx bytes per subsystem — the scarce resource. */ + val mobileBytesPerSubsystem: Map, + /** foreground time per screen NAME (arguments are never recorded). */ + val screenTimeMs: Map, + /** how many day buckets this summary was built from (>= 1). */ + val dayCount: Int, ) { val totalBytes: Long get() = mobileBytesBg + mobileBytesFg + wifiBytesBg + wifiBytesFg val mobileBytes: Long get() = mobileBytesBg + mobileBytesFg val relayConnMs: Long get() = relayConnMsMobileBg + relayConnMsMobileFg + relayConnMsWifiBg + relayConnMsWifiFg companion object { - fun from(counters: Map): UsageSummary { + fun from( + counters: Map, + dayCount: Int = 1, + ): UsageSummary { fun traffic( net: String, vis: String, ) = counters.sumMatching(net, vis, UsageKeys.RX) + counters.sumMatching(net, vis, UsageKeys.TX) val subsystems = mutableMapOf() + val mobileSubsystems = mutableMapOf() for (role in UsageKeys.HTTP_ROLES) { val bytes = counters.sumMatching(role, UsageKeys.RX) + counters.sumMatching(role, UsageKeys.TX) if (bytes > 0) subsystems[role] = bytes + val mobileBytes = + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching(role, UsageKeys.MOBILE, UsageKeys.TX) + if (mobileBytes > 0) mobileSubsystems[role] = mobileBytes } val relayBytes = counters.sumMatching("msg", UsageKeys.RX) + counters.sumMatching("msg", UsageKeys.TX) if (relayBytes > 0) subsystems["relay"] = relayBytes + val mobileRelayBytes = + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.RX) + + counters.sumMatching("msg", UsageKeys.MOBILE, UsageKeys.TX) + if (mobileRelayBytes > 0) mobileSubsystems["relay"] = mobileRelayBytes + + val screens = mutableMapOf() + for ((key, value) in counters) { + if (key.startsWith(UsageKeys.SCREEN_PREFIX) && key.endsWith(".ms") && value > 0) { + val name = key.removePrefix(UsageKeys.SCREEN_PREFIX).removeSuffix(".ms") + if (name.isNotBlank()) screens[name] = (screens[name] ?: 0L) + value + } + } return UsageSummary( mobileBytesBg = traffic(UsageKeys.MOBILE, UsageKeys.BG), @@ -110,6 +137,7 @@ data class UsageSummary( mediaPlayMs = counters[UsageKeys.MEDIA_PLAY_MS] ?: 0L, powMs = counters[UsageKeys.POW_MS] ?: 0L, torMs = counters[UsageKeys.TOR_MS] ?: 0L, + torStarts = counters[UsageKeys.TOR_STARTS] ?: 0L, alwaysOnMs = counters[UsageKeys.ALWAYS_ON_MS] ?: 0L, alwaysOnStarts = counters[UsageKeys.ALWAYS_ON_STARTS] ?: 0L, callMs = counters[UsageKeys.CALL_MS] ?: 0L, @@ -122,6 +150,9 @@ data class UsageSummary( batteryDrainFg = counters[UsageKeys.BATTERY_DRAIN_FG] ?: 0L, batteryDrainBg = counters[UsageKeys.BATTERY_DRAIN_BG] ?: 0L, bytesPerSubsystem = subsystems, + mobileBytesPerSubsystem = mobileSubsystems, + screenTimeMs = screens, + dayCount = dayCount.coerceAtLeast(1), ) } @@ -129,7 +160,7 @@ data class UsageSummary( fun fromDays(days: Collection>): UsageSummary { val merged = mutableMapOf() days.forEach { day -> day.forEach { (k, v) -> merged[k] = (merged[k] ?: 0L) + v } } - return from(merged) + return from(merged, dayCount = days.size) } } } 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 e5e7c77fa2..d4866a71b0 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 @@ -42,13 +42,16 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.util.Consumer import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert +import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen @@ -303,6 +306,7 @@ fun AppNavigation( } } + TrackScreenTime(nav) NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) @@ -327,6 +331,27 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) { } } +/** + * Feeds the resource-usage ledger with time-per-screen. Only the route's + * base name crosses this boundary — [ScreenTimeIntegrator.screenNameOf] + * strips every navigation argument first, so the ledger can say "Profile" + * but never which profile. + */ +@Composable +private fun TrackScreenTime(nav: Nav) { + DisposableEffect(nav.controller) { + val listener = + NavController.OnDestinationChangedListener { _, destination, _ -> + Amethyst.instance.screenTime.onScreen(ScreenTimeIntegrator.screenNameOf(destination.route)) + } + nav.controller.addOnDestinationChangedListener(listener) + onDispose { + nav.controller.removeOnDestinationChangedListener(listener) + Amethyst.instance.screenTime.onScreen(null) + } + } +} + @Composable fun BuildNavigation( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 2b972a6d14..2e9f97683b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -61,10 +61,12 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.collectMemorySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.crashreports.DEV_REPORT_PUBKEY +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs +import com.vitorpamplona.amethyst.service.resourceusage.UsageInsights import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -75,6 +77,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import java.util.Locale /** * The resource-usage ledger: how much network, relay connection time, and @@ -136,14 +139,18 @@ fun ResourceUsageScreen( val weekSummary = remember(loaded) { UsageSummary.fromDays((today - 6..today).mapNotNull { loaded[it] }) } TodayTiles(todaySummary) + WeekRatesTiles(weekSummary) + InsightsSection(weekSummary, nav) if (weekSummary.totalBytes > 0) { SettingsSection(R.string.resource_usage_trend_section) { UsageTrendChart(loaded, today) } } SubsystemSection(weekSummary) + ScreenTimeSection(weekSummary) ActivitySection(weekSummary) AlwaysOnServiceSection(weekSummary, nav) + TorServiceSection(weekSummary, nav) MemorySection(memory) SendReportSection(accountViewModel, nav, loaded, today, memory) } @@ -196,9 +203,120 @@ private fun StatTile( } } -/** Ranked per-feature traffic with proportion bars (7 days). */ +/** + * 7-day rates: totals aren't judgeable, rates are. Battery %/hour of use is + * the most tangible battery number we can show; average simultaneous relay + * connections is the number a user can act on by trimming their relay list. + */ +@Composable +private fun WeekRatesTiles(s: UsageSummary) { + val fgHours = s.foregroundMs / 3_600_000.0 + val tiles = + buildList { + if (fgHours >= 0.5 && s.batteryDrainFg > 0) { + add(R.string.resource_usage_tile_battery_rate to String.format(Locale.US, "%.1f%%", s.batteryDrainFg / fgHours)) + } + if (fgHours >= 0.5) { + add(R.string.resource_usage_tile_data_rate to formatBytes(((s.mobileBytesFg + s.wifiBytesFg) / fgHours).toLong())) + } + val avgRelays = s.relayConnMs.toDouble() / (s.dayCount * ResourceUsageAccountant.DAY_MS) + if (avgRelays >= 0.5) { + add(R.string.resource_usage_tile_avg_relays to String.format(Locale.US, "%.1f", avgRelays)) + } + } + if (tiles.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + tiles.forEach { (label, value) -> StatTile(label, value, Modifier.weight(1f)) } + } +} + +/** + * Up to three plain-language recommendations, each deep-linking to the + * setting that acts on it — the rules live in [UsageInsights] so they are + * unit-testable and shared with nothing UI-bound. + */ +@Composable +private fun InsightsSection( + week: UsageSummary, + nav: INav, +) { + val insights = remember(week) { UsageInsights.evaluate(week) } + if (insights.isEmpty()) return + SettingsSection(R.string.resource_usage_insights_section) { + insights.forEachIndexed { index, insight -> + if (index > 0) SettingsDivider() + Column( + modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = insightText(insight), + style = MaterialTheme.typography.bodyMedium, + ) + TextButton( + onClick = { nav.nav(insightRoute(insight.target)) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } + } + } + } +} + +@Composable +private fun insightText(insight: UsageInsights.Insight): String = + when (insight.target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> + stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) + UsageInsights.Target.MEDIA_SETTINGS -> + stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) + UsageInsights.Target.RELAY_SETTINGS -> + stringRes(R.string.resource_usage_insight_relays, insight.value.toString()) + UsageInsights.Target.PRIVACY_SETTINGS -> + stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) + } + +private fun insightRoute(target: UsageInsights.Target): Route = + when (target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings + UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings + UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions + } + +@StringRes +private fun insightButton(target: UsageInsights.Target): Int = + when (target) { + UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button + UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media + UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy + } + +/** + * Ranked per-feature traffic with proportion bars (7 days). Cellular is the + * scarce resource (battery and often money), so when any cellular traffic + * exists the ranking, bars, and headline value are cellular — with the total + * as secondary context. Wi-Fi-only devices fall back to totals. + */ @Composable private fun SubsystemSection(week: UsageSummary) { + val cellular = week.mobileBytesPerSubsystem + if (cellular.isNotEmpty()) { + val rows = cellular.entries.sortedByDescending { it.value } + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_by_subsystem_cellular) { + rows.forEach { (subsystem, bytes) -> + BarRow( + label = subsystemLabel(subsystem), + value = stringRes(R.string.resource_usage_cell_of_total, formatBytes(bytes), formatBytes(week.bytesPerSubsystem[subsystem] ?: bytes)), + fraction = bytes.toFloat() / max.toFloat(), + ) + } + } + return + } if (week.bytesPerSubsystem.isEmpty()) return val rows = week.bytesPerSubsystem.entries.sortedByDescending { it.value } val max = rows.first().value.coerceAtLeast(1L) @@ -213,6 +331,29 @@ private fun SubsystemSection(week: UsageSummary) { } } +/** + * Where the screen-on time went (7 days). Route base names only — the + * ledger never records which profile/hashtag/thread a screen showed. + */ +@Composable +private fun ScreenTimeSection(week: UsageSummary) { + if (week.screenTimeMs.isEmpty()) return + val rows = + week.screenTimeMs.entries + .sortedByDescending { it.value } + .take(6) + val max = rows.first().value.coerceAtLeast(1L) + SettingsSection(R.string.resource_usage_screen_time_section) { + rows.forEach { (name, ms) -> + BarRow( + label = name, + value = formatDurationMs(ms), + fraction = ms.toFloat() / max.toFloat(), + ) + } + } +} + @StringRes private fun subsystemLabel(subsystem: String): Int = when (subsystem) { @@ -227,13 +368,21 @@ private fun subsystemLabel(subsystem: String): Int = else -> R.string.resource_usage_subsystem_other } -/** Label + value + a thin rounded proportion bar underneath. */ @Composable private fun BarRow( @StringRes label: Int, value: String, fraction: Float, color: Color = MaterialTheme.colorScheme.primary, +) = BarRow(stringRes(label), value, fraction, color) + +/** Label + value + a thin rounded proportion bar underneath. */ +@Composable +private fun BarRow( + label: String, + value: String, + fraction: Float, + color: Color = MaterialTheme.colorScheme.primary, ) { Column( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), @@ -241,7 +390,7 @@ private fun BarRow( ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = stringRes(label), + text = label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -449,6 +598,43 @@ private fun AlwaysOnServiceSection( } } +/** + * Cost card for in-app Tor — like the always-on card: what it cost this + * week and the settings surface that controls it. + */ +@Composable +private fun TorServiceSection( + s: UsageSummary, + nav: INav, +) { + if (s.torMs <= 0) return + val starts = s.torStarts.toInt() + SettingsSection(R.string.resource_usage_tor_section) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.resource_usage_tor_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + MetricRow( + R.string.resource_usage_alwayson_uptime, + "${formatDurationMs(s.torMs)} · ${pluralStringResource(R.plurals.resource_usage_alwayson_starts, starts, starts)}", + ) + Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { + TextButton( + onClick = { nav.nav(Route.PrivacyOptions) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(R.string.resource_usage_insight_button_privacy)) + } + } + } +} + @Composable private fun SendReportSection( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index df93f3236c..53ad796d07 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3227,6 +3227,22 @@ Relay connections held while closed Battery drained meanwhile (whole device) Change notification settings + What’s using your battery + Relay connections held while the app was closed are your largest background cost (%1$s). + %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. + The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. + Media settings + Edit relays + Privacy options + Battery / hour in app + Data / hour in app + Avg. relay connections + Cellular data by feature (7 days) + %1$s · %2$s total + Time by screen (7 days) + Built-in Tor + Routes the app’s traffic through the Tor network for privacy. While running it spends extra battery on encryption and keep-alive traffic, and every start pays a bootstrap. If your threat model allows, Tor use can be adjusted or turned off. Data by feature (7 days) Relay sync Images diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index 6a2e216392..a4ae9b092a 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -617,6 +617,164 @@ class MeteringNostrSignerTest { } } +class ScreenTimeIntegratorTest { + @get:Rule + val temp = TemporaryFolder() + + @Test + fun routeNamesLoseTheirArgumentsBeforeAnythingIsRecorded() { + assertEquals("Profile", ScreenTimeIntegrator.screenNameOf("com.vitorpamplona.amethyst.ui.navigation.routes.Route.Profile/{userId}")) + assertEquals("Hashtag", ScreenTimeIntegrator.screenNameOf("routes.Route.Hashtag/{tag}?extra={extra}")) + assertEquals("Home", ScreenTimeIntegrator.screenNameOf("routes.Route.Home")) + assertNull(ScreenTimeIntegrator.screenNameOf(null)) + assertNull(ScreenTimeIntegrator.screenNameOf("")) + } + + @Test + fun accountsScreenTimeOnlyWhileForeground() = + runTest { + val store = ResourceUsageStore(File(temp.root, "u.json")) + val accountant = ResourceUsageAccountant(store, backgroundScope, epochDay = { 100L }) + var clock = 0L + val isForeground = MutableStateFlow(true) + val integrator = ScreenTimeIntegrator(accountant, nowMs = { clock }) + integrator.start(backgroundScope, isForeground) + testScheduler.runCurrent() + + integrator.onScreen("Home") + testScheduler.runCurrent() + clock += 5_000 + integrator.onScreen("Video") + testScheduler.runCurrent() + clock += 3_000 + isForeground.value = false // backgrounded on Video: segment closes + testScheduler.runCurrent() + clock += 60_000 // background time must not count + isForeground.value = true + testScheduler.runCurrent() + clock += 2_000 + integrator.onScreen(null) + testScheduler.runCurrent() + + val today = accountant.allDaysIncludingLive()[100].orEmpty() + assertEquals(5_000L, today[UsageKeys.screenMs("Home")]) + assertEquals(5_000L, today[UsageKeys.screenMs("Video")]) + } +} + +class UsageInsightsTest { + private fun summary( + counters: Map, + days: Int, + ) = UsageSummary.fromDays(List(days) { if (it == 0) counters else emptyMap() }) + + @Test + fun quietWeekYieldsNoInsights() { + val s = summary(mapOf(UsageKeys.APP_FG_MS to 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).isEmpty()) + } + + @Test + fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) + } + + @Test + fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { + val s = + summary( + mapOf(UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) + } + + @Test + fun cellularMediaSuggestsMediaSettingsButWifiDoesNot() { + val cellular = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertEquals(UsageInsights.Target.MEDIA_SETTINGS, UsageInsights.evaluate(cellular).first().target) + + val wifi = + summary( + mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(wifi).isEmpty()) + } + + @Test + fun manySimultaneousRelaysSuggestsEditingTheRelayList() { + // 40 relays open around the clock for a week. + val s = + summary( + mapOf(UsageKeys.relayConnMs(mobile = false, foreground = true) to 40L * 7L * 24L * 3_600_000L), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_SETTINGS }) + } + + @Test + fun longTorUptimeSuggestsPrivacyOptions() { + val s = summary(mapOf(UsageKeys.TOR_MS to 7L * 5L * 3_600_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun thresholdsScaleWithTheNumberOfObservedDays() { + // 5 tor-hours looks fine over a week but heavy over a single day. + val counters = mapOf(UsageKeys.TOR_MS to 5L * 3_600_000L) + assertTrue(UsageInsights.evaluate(summary(counters, days = 7)).isEmpty()) + assertTrue(UsageInsights.evaluate(summary(counters, days = 1)).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun neverMoreThanThreeInsights() { + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, + UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, + UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, + ), + days = 7, + ) + assertEquals(UsageInsights.MAX_INSIGHTS, UsageInsights.evaluate(s).size) + } +} + +class UsageSummaryMapsTest { + @Test + fun screenTimeAndCellularMapsAreExtractedFromCounters() { + val s = + UsageSummary.from( + mapOf( + UsageKeys.screenMs("Home") to 10_000L, + UsageKeys.screenMs("Video") to 5_000L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 100L, + UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 900L, + ), + ) + assertEquals(10_000L, s.screenTimeMs["Home"]) + assertEquals(5_000L, s.screenTimeMs["Video"]) + assertEquals(100L, s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + assertEquals(1_000L, s.bytesPerSubsystem[UsageKeys.ROLE_IMAGE]) + } +} + class ResourceUsageAlertsTest { private fun day(vararg counters: Pair) = mapOf(*counters) From 95ab9e6528a4a64219bae1bfd41749ce5a1fc076 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 14:14:30 -0400 Subject: [PATCH 124/206] 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 125/206] 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 9d176edd1c278d771bae98151e4f1d91fb54656c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 20:23:16 +0000 Subject: [PATCH 126/206] =?UTF-8?q?fix:=20make=20usage=20insights=20accura?= =?UTF-8?q?te=20=E2=80=94=20impact=20ranking,=20honest=20attribution,=20mi?= =?UTF-8?q?ssing=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accuracy fixes: - Insights are ranked by an estimated-impact score (rough hours of extra radio/CPU activity, ranking only, never displayed) before the cut to three — declaration order no longer decides which consumer gets dropped. - The notification insight states the measurement ('held connections open for N relay-hours in the background') instead of an unverified 'largest background cost' superlative, and only fires when the service's uptime covers at least half the background window — a service that ran 20 minutes can no longer be blamed for 30 hours of background connections. - When the measured drain split shows foreground use dominating (>=3x the background share with enough signal), an informational note leads the list saying most battery went to screen-on use — so nobody flips a setting expecting savings it cannot deliver. Completeness — new rules, each with a deep link: - PoW mining time -> compose settings (default difficulty), scored as full-tilt CPU. - Reconnect churn -> relay list (a flaky relay burns a handshake plus a radio wake-up per retry) — distinct diagnosis from 'too many relays'. - Push-processing wakelock time / process-start churn -> notification settings. Count strings converted to plurals per res/CLAUDE.md. Insight test suite extended to 15 cases covering the attribution gate, score ordering, the honesty note, and each new rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../service/resourceusage/UsageInsights.kt | 104 ++++++++++++++---- .../loggedIn/settings/ResourceUsageScreen.kt | 39 +++++-- amethyst/src/main/res/values/strings.xml | 14 ++- .../resourceusage/ResourceUsageLedgerTest.kt | 85 +++++++++++++- 4 files changed, 211 insertions(+), 31 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt index 87811e2b30..c60e095cf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt @@ -21,30 +21,47 @@ package com.vitorpamplona.amethyst.service.resourceusage /** - * Turns a week of counters into at most [MAX_INSIGHTS] actionable + * Turns a multi-day summary into at most [MAX_INSIGHTS] actionable * recommendations, each mapping to a setting the user can actually change. * The numbers alone make the user the analyst; these rules encode the * analysis (thresholds informed by the 2026-07-12 ping study) and leave the * user only the decision. * - * Rules are ordered by typical battery impact; unlike [ResourceUsageAlerts] - * (which detects "something is wrong" and interrupts), insights render - * passively on the usage screen and use much lower thresholds — "worth - * knowing", not "pathological". + * Candidates that fire are ranked by an estimated-impact [score] — a rough + * conversion of each signal to "hours of extra radio/CPU activity" — so the + * cut to [MAX_INSIGHTS] drops the smallest consumers, not whichever rule + * happened to be declared last. Scores are order-of-magnitude heuristics for + * RANKING ONLY and are never shown to the user. + * + * When the measured battery split says foreground use dominates, a non-action + * [Target.FOREGROUND_INFO] note leads the list: most drain being screen-on + * time is the honest headline, and without it users would act on a minor + * insight expecting savings the settings can't deliver. + * + * Unlike [ResourceUsageAlerts] (which detects "something is wrong" and + * interrupts), insights render passively on the usage screen and use much + * lower thresholds — "worth knowing", not "pathological". */ object UsageInsights { /** Each target names the settings surface that can act on the insight. */ enum class Target { + /** Informational only — no button. Foreground/screen use dominates the measured drain. */ + FOREGROUND_INFO, NOTIFICATION_SETTINGS, MEDIA_SETTINGS, RELAY_SETTINGS, + RELAY_CHURN, + POW_SETTINGS, + PUSH_PROCESSING, PRIVACY_SETTINGS, } data class Insight( val target: Target, - /** ms for time-based insights, bytes for data, count for relays. */ + /** ms for time-based insights, bytes for data, counts for relays/restarts, percent for FOREGROUND_INFO. */ val value: Long, + /** Estimated impact in "hours of extra radio/CPU activity" — ranking only, never displayed. */ + val score: Double, ) /** @@ -53,46 +70,95 @@ object UsageInsights { */ fun evaluate(s: UsageSummary): List { val days = s.dayCount.coerceAtLeast(1) - val insights = mutableListOf() + val candidates = mutableListOf() - // Background relay connections dominate drain while the always-on - // service is in use — the one consumer with a dedicated off switch. + // Background relay connections, attributed to the always-on service + // ONLY when its uptime actually covers most of the background window — + // a service that ran 20 minutes cannot own 30 hours of background + // connections (long background audio sessions also hold relays). val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg - if (s.alwaysOnMs > 0 && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs) + val bgWallMs = (days * ResourceUsageAccountant.DAY_MS - s.foregroundMs).coerceAtLeast(1L) + val serviceCoversBackground = s.alwaysOnMs * 2 >= bgWallMs + if (serviceCoversBackground && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { + candidates += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs, score = hours(s.alwaysOnMs)) } // Cellular media: images/video/previews can be limited to Wi-Fi. + // Score: scattered mobile downloads pay radio ramp+tail per burst; + // ~60 MB of feed media ≈ an hour of radio-active time. val cellularMediaBytes = (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { - insights += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes) + candidates += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes, score = cellularMediaBytes / (60.0 * 1024 * 1024)) } - // Average simultaneous relay connections across the whole period: - // every open connection is server-pinged every 30-70s, so the radio - // never sleeps while they're up. Fewer relays = less radio time. + // Average simultaneous relay connections: every open connection is + // server-pinged every 30-70s. Score: marginal — the radio is often up + // anyway; extra relays add pings, duplicates, and handshakes. val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) if (avgRelays > AVG_RELAYS) { - insights += Insight(Target.RELAY_SETTINGS, avgRelays) + candidates += Insight(Target.RELAY_SETTINGS, avgRelays, score = hours(s.relayConnMs) / 100.0) } - // In-app Tor pays circuit crypto + keep-alives for as long as it runs. + // Reconnect churn: a flaky relay in the list burns a TCP+TLS + // handshake plus a radio burst per retry. Score: ~15s of radio each. + val reconnects = s.relayConnects + s.relayConnectFails + if (reconnects > days * RECONNECTS_PER_DAY) { + candidates += Insight(Target.RELAY_CHURN, reconnects, score = reconnects * 15.0 / 3600.0) + } + + // NIP-13 mining: multi-core CPU flat out — roughly 3x the drain rate + // of an ordinary radio-active hour. + if (s.powMs > days * POW_MINUTES_PER_DAY * MS_PER_MINUTE) { + candidates += Insight(Target.POW_SETTINGS, s.powMs, score = hours(s.powMs) * 3.0) + } + + // Push processing: wakelock time plus process cold starts (each + // restart re-parses, re-connects, and pays a radio burst, ~10s each). + val pushScore = hours(s.wakelockMs) + s.appStarts * 10.0 / 3600.0 + if (s.wakelockMs > days * WAKELOCK_MINUTES_PER_DAY * MS_PER_MINUTE || s.appStarts > days * APP_STARTS_PER_DAY) { + candidates += Insight(Target.PUSH_PROCESSING, s.wakelockMs, score = pushScore) + } + + // In-app Tor: circuit crypto + keep-alives are overhead ON TOP of + // traffic that would exist anyway — count a fraction of its uptime. if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { - insights += Insight(Target.PRIVACY_SETTINGS, s.torMs) + candidates += Insight(Target.PRIVACY_SETTINGS, s.torMs, score = hours(s.torMs) * 0.3) } - return insights.take(MAX_INSIGHTS) + val ranked = candidates.sortedByDescending { it.score }.take(MAX_INSIGHTS) + + // Honesty note: when the measured split says the battery went to + // screen-on use, say so first — settings mainly reduce the background + // share, and the user deserves to know how big that share is. + val fgDominates = + s.batteryDrainFg >= MIN_DRAIN_SIGNAL_PCT && + s.batteryDrainFg >= 3 * s.batteryDrainBg.coerceAtLeast(1L) + return if (fgDominates) { + listOf(Insight(Target.FOREGROUND_INFO, s.batteryDrainFg, score = 0.0)) + ranked + } else { + ranked + } } + private fun hours(ms: Long): Double = ms / MS_PER_HOUR.toDouble() + const val MAX_INSIGHTS = 3 private const val MS_PER_HOUR = 60L * 60L * 1000L + private const val MS_PER_MINUTE = 60L * 1000L // Per-day thresholds — deliberately well below the alert levels. const val BG_RELAY_HOURS_PER_DAY = 3L const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L const val AVG_RELAYS = 25L + const val RECONNECTS_PER_DAY = 500L + const val POW_MINUTES_PER_DAY = 10L + const val WAKELOCK_MINUTES_PER_DAY = 5L + const val APP_STARTS_PER_DAY = 15L const val TOR_HOURS_PER_DAY = 4L + + /** Foreground drain below this many percent points is too noisy to call a trend. */ + const val MIN_DRAIN_SIGNAL_PCT = 5L } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index 2e9f97683b..bd974db39b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -253,11 +253,14 @@ private fun InsightsSection( text = insightText(insight), style = MaterialTheme.typography.bodyMedium, ) - TextButton( - onClick = { nav.nav(insightRoute(insight.target)) }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringRes(insightButton(insight.target))) + val route = insightRoute(insight.target) + if (route != null) { + TextButton( + onClick = { nav.nav(route) }, + modifier = Modifier.align(Alignment.End), + ) { + Text(stringRes(insightButton(insight.target))) + } } } } @@ -267,30 +270,50 @@ private fun InsightsSection( @Composable private fun insightText(insight: UsageInsights.Insight): String = when (insight.target) { + UsageInsights.Target.FOREGROUND_INFO -> + stringRes(R.string.resource_usage_insight_foreground, insight.value.toString()) UsageInsights.Target.NOTIFICATION_SETTINGS -> stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) UsageInsights.Target.MEDIA_SETTINGS -> stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) - UsageInsights.Target.RELAY_SETTINGS -> - stringRes(R.string.resource_usage_insight_relays, insight.value.toString()) + UsageInsights.Target.RELAY_SETTINGS -> { + val relays = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_relays, relays, relays) + } + UsageInsights.Target.RELAY_CHURN -> { + val reconnects = insight.value.toInt() + pluralStringResource(R.plurals.resource_usage_insight_churn, reconnects, reconnects) + } + UsageInsights.Target.POW_SETTINGS -> + stringRes(R.string.resource_usage_insight_pow, formatDurationMs(insight.value)) + UsageInsights.Target.PUSH_PROCESSING -> + stringRes(R.string.resource_usage_insight_push, formatDurationMs(insight.value)) UsageInsights.Target.PRIVACY_SETTINGS -> stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) } -private fun insightRoute(target: UsageInsights.Target): Route = +private fun insightRoute(target: UsageInsights.Target): Route? = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> null UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays + UsageInsights.Target.RELAY_CHURN -> Route.EditRelays + UsageInsights.Target.POW_SETTINGS -> Route.ComposeSettings + UsageInsights.Target.PUSH_PROCESSING -> Route.NotificationSettings UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions } @StringRes private fun insightButton(target: UsageInsights.Target): Int = when (target) { + UsageInsights.Target.FOREGROUND_INFO -> R.string.resource_usage_insights_section UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.RELAY_CHURN -> R.string.resource_usage_insight_button_relays + UsageInsights.Target.POW_SETTINGS -> R.string.compose_settings + UsageInsights.Target.PUSH_PROCESSING -> R.string.resource_usage_alwayson_settings_button UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 53ad796d07..5067aaf011 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3228,9 +3228,19 @@ Battery drained meanwhile (whole device) Change notification settings What’s using your battery - Relay connections held while the app was closed are your largest background cost (%1$s). + The always-on notification service held relay connections open for %1$s while the app was in the background. + Most of your measured battery use (%1$s%%) happened while you were actively using the app — screen and browsing, not background activity. Settings changes mainly reduce the background share. + + Relays reconnected %1$d time. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + Relays reconnected %1$d times. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. + + Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly. + Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it. %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. - The app kept %1$s relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + + The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. + Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. Media settings Edit relays diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index a4ae9b092a..e972982b3d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -676,10 +676,11 @@ class UsageInsightsTest { @Test fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { + // Service ran 100 of the ~168 background hours: it owns the connections. val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 100L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, ), days = 7, @@ -688,6 +689,86 @@ class UsageInsightsTest { assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) } + @Test + fun briefServiceUptimeIsNotBlamedForBackgroundConnectionsItCannotOwn() { + // Service ran 1h all week; 28h of background connections came from + // something else (e.g. background audio) — no notification insight. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 1L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) + } + + @Test + fun insightsAreRankedByEstimatedImpactNotDeclarationOrder() { + // PoW at 100h scores ~300; the notification insight scores ~150 — + // PoW must come first even though its rule is declared later. + val s = + summary( + mapOf( + UsageKeys.ALWAYS_ON_MS to 150L * 3_600_000L, + UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, + UsageKeys.POW_MS to 100L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.POW_SETTINGS, insights[0].target) + assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights[1].target) + } + + @Test + fun powMiningTimeSuggestsComposeSettings() { + val s = summary(mapOf(UsageKeys.POW_MS to 7L * 15L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.POW_SETTINGS }) + } + + @Test + fun reconnectChurnSuggestsReviewingTheRelayList() { + val s = summary(mapOf(UsageKeys.relayConnects(mobile = true, foreground = false) to 7L * 600L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_CHURN }) + } + + @Test + fun pushProcessingTimeSuggestsNotificationSettings() { + val s = summary(mapOf(UsageKeys.WAKELOCK_NOTIF_MS to 7L * 10L * 60_000L), days = 7) + assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PUSH_PROCESSING }) + } + + @Test + fun foregroundDominatedDrainLeadsWithTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 20L, + UsageKeys.BATTERY_DRAIN_BG to 2L, + UsageKeys.TOR_MS to 7L * 5L * 3_600_000L, + ), + days = 7, + ) + val insights = UsageInsights.evaluate(s) + assertEquals(UsageInsights.Target.FOREGROUND_INFO, insights.first().target) + assertTrue(insights.any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) + } + + @Test + fun balancedDrainDoesNotAddTheHonestyNote() { + val s = + summary( + mapOf( + UsageKeys.BATTERY_DRAIN_FG to 10L, + UsageKeys.BATTERY_DRAIN_BG to 8L, + ), + days = 7, + ) + assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.FOREGROUND_INFO }) + } + @Test fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { val s = @@ -745,7 +826,7 @@ class UsageInsightsTest { val s = summary( mapOf( - UsageKeys.ALWAYS_ON_MS to 24L * 3_600_000L, + UsageKeys.ALWAYS_ON_MS to 168L * 3_600_000L, UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, From 28b93132a3ebf2d12f9cd46871f1b63aa9c1b7e9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:16:12 -0400 Subject: [PATCH 127/206] =?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 e8ecf429a20438c7531faa279cbadd6fbfe8103f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:20:36 +0000 Subject: [PATCH 128/206] refactor: move nickname policy, emoji autocomplete, filters and dialog to commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the remaining platform-bound pieces of the nickname feature so the desktop app can adopt it: - ContactCardsState.displayNameFlow/cachedDisplayName own the NIP-81 render policy (petname over profile name over npub); the Android observeUserName is now a thin wrapper around it - EmojiSuggestionState.autocompleteInto completes the word under the cursor and closes the list — replaces the insert+reset pair copy-pasted in the 8 composer ViewModels and the nickname dialog - the kind:30382 filter builders move to commons relayClient/assemblers (cards about targets from trusted accounts, and the account's own cards by author), shared by the user watcher and the login subscription - ShowEmojiSuggestionList moves to commons nip30CustomEmojis/ui using coil and commons string resources - EditNicknameDialog moves to commons nip85TrustedAssertions/ui: it takes the ContactCardsState and an onSave callback, so each front end only wires its own publish path and menu entry; dialog strings move to commons resources Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../FilterAccountInfoAndListsFromKey.kt | 21 +--- .../reqCommand/user/UserObservers.kt | 22 +--- .../user/watchers/UserCardsSubAssembler.kt | 1 + .../nip22Comments/CommentPostViewModel.kt | 6 +- .../nip22Comments/GenericCommentPostScreen.kt | 2 +- .../privateDM/send/ChatNewMessageViewModel.kt | 6 +- .../chats/privateDM/send/NewGroupDMScreen.kt | 2 +- .../send/PrivateMessageEditFieldRow.kt | 2 +- .../send/ChannelNewMessageViewModel.kt | 5 +- .../chats/publicChannels/send/EditFieldRow.kt | 2 +- .../nip23LongForm/LongFormPostScreen.kt | 2 +- .../nip23LongForm/LongFormPostViewModel.kt | 4 +- .../nip99Classifieds/NewProductScreen.kt | 2 +- .../nip99Classifieds/NewProductViewModel.kt | 6 +- .../loggedIn/home/ShortNotePostScreen.kt | 2 +- .../loggedIn/home/ShortNotePostViewModel.kt | 6 +- .../nests/room/chat/NestEditFieldRow.kt | 2 +- .../room/chat/NestNewMessageViewModel.kt | 5 +- .../publicMessages/NewPublicMessageScreen.kt | 2 +- .../NewPublicMessageViewModel.kt | 6 +- .../profile/header/UserProfileDropDownMenu.kt | 7 +- amethyst/src/main/res/values/strings.xml | 6 +- .../composeResources/values/strings.xml | 11 ++ .../nip30CustomEmojis/EmojiSuggestionState.kt | 14 +++ .../ContactCardsState.kt | 17 +++ .../ui}/ShowEmojiSuggestionList.kt | 22 ++-- .../ui}/EditNicknameDialog.kt | 110 +++++++++--------- .../assemblers/ContactCardFilters.kt | 29 ++++- 28 files changed, 175 insertions(+), 147 deletions(-) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui}/ShowEmojiSuggestionList.kt (86%) rename {amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui}/EditNicknameDialog.kt (55%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt => commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt (69%) 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 967bbd4f45..673e6cba60 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -49,7 +50,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent @@ -94,12 +94,6 @@ val AccountInfoAndListsFromKeyKinds2 = NutzapInfoEvent.KIND, ) -// The account's own kind:30382 contact cards (nicknames/petnames for other -// users, NIP-44 encrypted). Addressable: one card per target user, so this is a -// collection rather than a single replaceable — kept out of the small-limit -// filters above and given its own filter with a larger limit. -val AccountContactCardKinds = listOf(ContactCardEvent.KIND) - val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG)) @@ -131,15 +125,12 @@ fun filterAccountInfoAndListsFromKey( since = since, ), ), - RelayBasedFilter( + // The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted). + // Addressable — one card per target user — hence its own larger-limit filter. + filterContactCardsByAuthorInTheRelay( relay = relay, - filter = - Filter( - kinds = AccountContactCardKinds, - authors = listOf(pubkey), - limit = 500, - since = since, - ), + author = pubkey, + since = since, ), RelayBasedFilter( relay = relay, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index beb8cb9182..26dcbe7c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -61,25 +61,11 @@ fun observeUserName( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user, accountViewModel) - val flow = - remember(user) { - combine( - user.metadata().flow, - accountViewModel.account.contactCards.petNameFlow(user), - ) { info, petName -> - petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex() - }.distinctUntilChanged() - } - - val initialName = - remember(user) { - accountViewModel.account.contactCards - .cachedPetName(user) - ?.petName ?: user.toBestDisplayName() - } + val contactCards = accountViewModel.account.contactCards + val flow = remember(user) { contactCards.displayNameFlow(user) } // Subscribe in the LocalCache for changes that arrive in the device - return flow.collectAsStateWithLifecycle(initialName) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) }) } /** @@ -323,7 +309,7 @@ fun observeUserBookmarkCount( val combined = remember(user) { - kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount -> + combine(newFlow, oldFlow) { newCount, oldCount -> newCount + oldCount } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index 19474b6c48..bd297744e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers import com.vitorpamplona.amethyst.commons.model.toHexSet +import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index b4d9c028ff..0943395636 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -920,13 +920,9 @@ open class CommentPostViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 8d4497e18d..ae2542cbcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles @@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index fc6ed14ce5..2d92030b7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -764,13 +764,9 @@ class ChatNewMessageViewModel : } fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index 36987b6160..8941be093f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.Amethyst 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo @@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index a089688b21..fc64efe0ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation @@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot 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 eab9d48766..377b424bbb 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 @@ -678,10 +678,7 @@ open class ChannelNewMessageViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } 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..f23c35fce7 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -44,7 +45,6 @@ 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.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 95cdc5577c..93c8a6b0ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -83,6 +83,7 @@ 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.EmptyTagList +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index cb6d7bdc80..47686de456 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -692,9 +692,7 @@ class LongFormPostViewModel : } fun autocompleteWithEmoji(item: EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 6f4fd20c97..9f32382d97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index f4206076ac..6e7554d7dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -558,13 +558,9 @@ open class NewProductViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 534927c0f1..7453377467 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -76,6 +76,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel 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.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS @@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 03e6d4c30b..c7ef8c9626 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -1610,13 +1610,9 @@ open class ShortNotePostViewModel : } open fun autocompleteWithEmoji(item: EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt index f6fa574b3e..88a8f1e51f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestEditFieldRow.kt @@ -36,13 +36,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt index dd164b0792..63a55b94c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/chat/NestNewMessageViewModel.kt @@ -548,10 +548,7 @@ open class NestNewMessageViewModel : } open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - message.replaceCurrentWord(wordToInsert) - - emojiSuggestions?.reset() + emojiSuggestions?.autocompleteInto(message, item) draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 06d46689e6..f10bd8db4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation @@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index ff9988c888..68ffbf6006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -621,13 +621,9 @@ class NewPublicMessageViewModel : } fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) { - val wordToInsert = ":${item.code}:" - - message.replaceCurrentWord(wordToInsert) + emojiSuggestions?.autocompleteInto(message, item) urlPreviews.update(message.text.toString()) - emojiSuggestions?.reset() - draftTag.newVersion() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt index 71e9ac8a38..258f7dbf82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserProfileDropDownMenu.kt @@ -29,11 +29,13 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNicknameDialog import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.externalLinkForUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -50,10 +52,13 @@ fun UserProfileDropDownMenu( val isNicknameDialogOpen = remember { mutableStateOf(false) } if (isNicknameDialogOpen.value) { + // keeps the account's selected emoji packs loaded for the : autocomplete + WatchAndLoadMyEmojiList(accountViewModel) EditNicknameDialog( user = user, + contactCards = accountViewModel.account.contactCards, + onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) }, onDismiss = { isNicknameDialogOpen.value = false }, - accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c6f381f16e..8c0897ec31 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3543,12 +3543,8 @@ Playback Auto - + Edit nickname - Nickname - Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. - Nickname - Private note about this user Cast to device diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index f7cbf115a9..e50c831382 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -90,4 +90,15 @@ Source: Servers: Open + + + Use direct URL + + + Nickname + Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis. + Nickname + Private note about this user + Save + Cancel diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt index 08dec98379..0f5e457153 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiSuggestionState.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow @@ -66,4 +68,16 @@ class EmojiSuggestionState( } } } + + /** + * Completes the word under the cursor in [field] with the selected emoji's + * `:shortcode:` and closes the suggestion list. + */ + fun autocompleteInto( + field: TextFieldState, + item: EmojiPackState.EmojiMedia, + ) { + field.replaceCurrentWord(":${item.code}:") + reset() + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 9ed53fc670..25b370002d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -33,6 +33,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -112,6 +113,22 @@ class ContactCardsState( return decryptionCache.cachedPetNameWithEmojis(card) } + /** + * The name to render for [target], per the NIP-81 policy: the nickname the + * account gave them wins over the profile's own display name, falling back + * to the short npub when neither exists. + */ + fun displayNameFlow(target: User): Flow = + combine( + target.metadata().flow, + petNameFlow(target), + ) { info, petName -> + petName?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex() + }.distinctUntilChanged() + + /** Synchronous first value for [displayNameFlow], from already-decrypted data. */ + fun cachedDisplayName(target: User): String = cachedPetName(target)?.petName ?: target.toBestDisplayName() + suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt index 953a044747..61d0048193 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/emojiSuggestions/ShowEmojiSuggestionList.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip30CustomEmojis/ui/ShowEmojiSuggestionList.kt @@ -18,7 +18,7 @@ * 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.note.creators.emojiSuggestions +package com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -39,15 +39,17 @@ import androidx.compose.ui.text.font.FontWeight 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.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.use_direct_url +import org.jetbrains.compose.resources.stringResource + +private val DividerThickness = 0.25.dp +private val RowSpacing = 10.dp +private val EmojiSize = Modifier.size(40.dp) @Composable fun ShowEmojiSuggestionList( @@ -73,23 +75,23 @@ fun ShowEmojiSuggestionList( bottom = 10.dp, ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = spacedBy(Size10dp), + horizontalArrangement = spacedBy(RowSpacing), ) { AsyncImage( it.link, contentDescription = it.code, - modifier = Size40Modifier, + modifier = EmojiSize, ) Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) IconButton( - modifier = Size40Modifier, + modifier = EmojiSize, onClick = { onFullSize(it) }, ) { Icon( symbol = MaterialSymbols.OpenInFull, - contentDescription = stringRes(R.string.use_direct_url), + contentDescription = stringResource(Res.string.use_direct_url), modifier = Modifier.size(20.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt similarity index 55% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt index 54f1a469a3..b24c98cbb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip85TrustedAssertions/ui/EditNicknameDialog.kt @@ -18,35 +18,41 @@ * 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.profile.header +package com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState +import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.nickname_cancel +import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_explainer +import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_title +import com.vitorpamplona.amethyst.commons.resources.nickname_label +import com.vitorpamplona.amethyst.commons.resources.nickname_save +import com.vitorpamplona.amethyst.commons.resources.nickname_summary_label import com.vitorpamplona.amethyst.commons.ui.text.currentWord -import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource /** * Edits the nickname (petname) and private note (summary) the account keeps for @@ -55,79 +61,82 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText * * Typing `:` offers the account's NIP-30 custom emojis; the mappings for any * shortcode used are embedded (also encrypted) so the nickname renders with them. + * + * Shared by every front end: the caller supplies the account's [contactCards] + * and publishes the result in [onSave] (e.g. through its outbox relays). On + * Android, compose `WatchAndLoadMyEmojiList` alongside so the emoji packs load. */ @Composable fun EditNicknameDialog( user: User, + contactCards: ContactCardsState, + onSave: (petName: String?, summary: String?) -> Unit, onDismiss: () -> Unit, - accountViewModel: AccountViewModel, ) { val nickname = rememberTextFieldState() val summary = rememberTextFieldState() - val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account.emoji) } - // which field the emoji autocomplete should insert into + val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) } + // which field the emoji autocomplete should insert into: the last one edited val emojiTarget = remember { mutableStateOf(null) } - // keeps the account's selected emoji packs loaded while the dialog is open - WatchAndLoadMyEmojiList(accountViewModel) - // Prefill with the card's current encrypted values, if any. Decryption can // be slow on external signers, so don't clobber anything already typed. LaunchedEffect(user) { - accountViewModel.account.contactCards + contactCards .petName(user.pubkeyHex) ?.takeIf { nickname.text.isEmpty() } ?.let { nickname.setTextAndPlaceCursorAtEnd(it) } - accountViewModel.account.contactCards + contactCards .summary(user.pubkeyHex) ?.takeIf { summary.text.isEmpty() } ?.let { summary.setTextAndPlaceCursorAtEnd(it) } } - fun watchEmojiIn(field: TextFieldState) { - emojiTarget.value = field - if (field.selection.collapsed) { - emojiSuggestions.processCurrentWord(field.currentWord()) + // Feed the word under the cursor of the last-edited field to the autocomplete. + LaunchedEffect(nickname, summary) { + fun watch(field: TextFieldState) { + emojiTarget.value = field + if (field.selection.collapsed) { + emojiSuggestions.processCurrentWord(field.currentWord()) + } } + launch { snapshotFlow { nickname.text }.collect { watch(nickname) } } + launch { snapshotFlow { summary.text }.collect { watch(summary) } } } AlertDialog( onDismissRequest = onDismiss, title = { - Text(text = stringRes(R.string.nickname_dialog_title)) + Text(text = stringResource(Res.string.nickname_dialog_title)) }, text = { Column( verticalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = stringRes(R.string.nickname_dialog_explainer), - color = MaterialTheme.colorScheme.placeholderText, + text = stringResource(Res.string.nickname_dialog_explainer), + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - ThinPaddingTextField( + OutlinedTextField( state = nickname, - onTextChanged = { watchEmojiIn(nickname) }, - singleLine = true, + lineLimits = TextFieldLineLimits.SingleLine, label = { - Text(text = stringRes(R.string.nickname_label)) + Text(text = stringResource(Res.string.nickname_label)) }, ) - ThinPaddingTextField( + OutlinedTextField( state = summary, - onTextChanged = { watchEmojiIn(summary) }, label = { - Text(text = stringRes(R.string.nickname_summary_label)) + Text(text = stringResource(Res.string.nickname_summary_label)) }, ) ShowEmojiSuggestionList( emojiSuggestions, - onSelect = { - emojiTarget.value?.replaceCurrentWord(":${it.code}:") - emojiSuggestions.reset() + onSelect = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } }, - onFullSize = { - emojiTarget.value?.replaceCurrentWord(":${it.code}:") - emojiSuggestions.reset() + onFullSize = { emoji -> + emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) } }, modifier = Modifier.heightIn(max = 200.dp), ) @@ -136,30 +145,27 @@ fun EditNicknameDialog( confirmButton = { Button( onClick = { - accountViewModel.updateContactCardPetName( - user = user, - petName = - nickname.text - .toString() - .trim() - .ifBlank { null }, - summary = - summary.text - .toString() - .trim() - .ifBlank { null }, + onSave( + nickname.text + .toString() + .trim() + .ifBlank { null }, + summary.text + .toString() + .trim() + .ifBlank { null }, ) onDismiss() }, ) { - Text(stringRes(R.string.save)) + Text(stringResource(Res.string.nickname_save)) } }, dismissButton = { Button( onClick = onDismiss, ) { - Text(stringRes(R.string.cancel)) + Text(stringResource(Res.string.nickname_cancel)) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt similarity index 69% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index 8013013f99..f75e6819f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterContactCardsToKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -18,7 +18,7 @@ * 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.service.relayClient.reqCommand.user.watchers +package com.vitorpamplona.amethyst.commons.relayClient.assemblers import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter @@ -28,6 +28,11 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent val ContactCardKindList = listOf(ContactCardEvent.KIND) +/** + * Kind:30382 cards *about* [targets], written by [trustedAccounts] (the account + * itself plus its WoT trust providers). Fetches nicknames and scores for the + * users currently on screen. + */ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( targets: Set, trustedAccounts: List, @@ -46,3 +51,25 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( ), ) } + +/** + * Every kind:30382 card *written by* [author] — the account's own nicknames — + * for the bulk download at login from the account's relays. Addressable events: + * one card per target user, hence the larger limit. + */ +fun filterContactCardsByAuthorInTheRelay( + relay: NormalizedRelayUrl, + author: HexKey, + since: Long?, + limit: Int = 500, +): RelayBasedFilter = + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = ContactCardKindList, + authors = listOf(author), + limit = limit, + since = since, + ), + ) From 413bf726fd68a0b62fa16494cd71ce035a58433a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:32:56 -0400 Subject: [PATCH 129/206] 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 130/206] 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 a6da2ee69da095210914f52b634b29f938c4d823 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:52:29 +0000 Subject: [PATCH 131/206] fix: record contact-card EOSEs from the d-tag so card syncs are incremental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserCardsSubAssembler.newEose read the filter's p-tags to stamp each target user's card EOSE, but kind:30382 filters address targets in the d-tag — so no per-user EOSE was ever recorded, groupByRelayPresence always classified users as never-checked, and every filter update re-downloaded the visible users' cards with since = null. Read the d-tag instead, and use DTag.TAG_NAME on both the filter builder and the EOSE reader so the two keys cannot drift apart again. Pre-existing on main; surfaced while verifying that card syncs are incremental from the account's outbox relays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../reqCommand/user/watchers/UserCardsSubAssembler.kt | 6 +++++- .../commons/relayClient/assemblers/ContactCardFilters.kt | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt index bd297744e8..5ab831c9c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserCardsSubAssembler.kt @@ -33,6 +33,7 @@ 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 +import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag import com.vitorpamplona.quartz.utils.mapOfSet class UserCardsSubAssembler( @@ -46,7 +47,10 @@ class UserCardsSubAssembler( filters: List?, ) { filters?.forEach { filter -> - filter.tags?.get("p")?.forEach { + // kind:30382 addresses the target user in the d-tag (the key the + // filter builder uses). Reading any other tag leaves the per-user + // EOSEs unset and forces full re-downloads with since = null. + filter.tags?.get(DTag.TAG_NAME)?.forEach { val targetUser = cache.getUserIfExists(it) targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt index f75e6819f2..9eab40c51a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/ContactCardFilters.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.tags.dTag.DTag import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent val ContactCardKindList = listOf(ContactCardEvent.KIND) @@ -46,7 +47,8 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay( Filter( kinds = ContactCardKindList, authors = trustedAccounts, - tags = mapOf("d" to targets.sorted()), + // kind:30382 addresses the target user in the d-tag + tags = mapOf(DTag.TAG_NAME to targets.sorted()), since = since, ), ) From 56d3d0681c634f1823b6e54b0042ae5da215c1b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:03:07 +0000 Subject: [PATCH 132/206] 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 133/206] 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 134/206] 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 135/206] 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 136/206] 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 137/206] 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 138/206] 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 ef3be707417c2bf286f20aaed392ac0eb2b157c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:02:21 +0000 Subject: [PATCH 139/206] fix: reject kind:1059 gift wraps with empty content before caching A gift wrap with an empty content string carries no NIP-44 ciphertext, so it can never be unwrapped. Filtering it at LocalCache's ingestion choke point (justConsumeInnerInner) rejects it before the Schnorr signature check and before it takes a permanent cache slot, and keeps GiftWrapEventHandler from retrying a doomed unwrap on every batch. Locally stripped wraps (copyNoContent, used to drop the ciphertext after a successful unwrap) are assigned directly to note.event and never pass through justConsume, so they are unaffected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV --- .../com/vitorpamplona/amethyst/model/LocalCache.kt | 10 +++++++++- 1 file changed, 9 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 58cdad1fc9..f1452106dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3847,7 +3847,15 @@ object LocalCache : ILocalCache, ICacheProvider { } is GiftWrapEvent -> { - consumeRegularEvent(event, relay, wasVerified) + // A wrap with an empty content carries no NIP-44 ciphertext and can + // never be unwrapped — reject it before paying for a signature check + // and a cache slot. Locally stripped copies (copyNoContent) are + // assigned straight to note.event and never pass through here. + if (event.content.isEmpty()) { + false + } else { + consumeRegularEvent(event, relay, wasVerified) + } } is GroupEvent -> { From bed5d93a12f15494962a2d104007b960be26a751 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 22:48:28 +0000 Subject: [PATCH 140/206] 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 141/206] 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 142/206] 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 b76b37744ea23e070c1e48df0dd3b9bee36f465e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:33:05 +0000 Subject: [PATCH 143/206] feat: nickname card on the user profile, above the real display name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile header no longer replaces the big display name with the petname. Instead, when the account nicknamed the user (or kept a private note about them), an outlined card renders above it — petname, divider, private summary — with the standard Lock private marker in its top-right corner, since both fields live NIP-44 encrypted in the account's contact card. Tapping the card opens the shared nickname editor. The profile's own display name stays fully visible underneath. Feeds, chats and mentions keep rendering the petname instead of the display name. To carry the summary into the UI, the commons PetName holder generalizes to Nickname(petName?, summary?, tags), built when either field exists — so a note-only card (no petname) now shows on the profile too, while the name override everywhere else keys strictly off petName. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../reqCommand/user/UserObservers.kt | 20 +-- .../amethyst/ui/components/ClickableRoute.kt | 9 +- .../amethyst/ui/components/RichTextViewer.kt | 9 +- .../amethyst/ui/note/UsernameDisplay.kt | 9 +- .../loggedIn/chats/feed/DrawAuthorInfo.kt | 11 +- .../profile/header/DrawAdditionalInfo.kt | 13 +- .../profile/header/UserNicknameCard.kt | 125 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + .../ContactCardDecryptionCache.kt | 16 ++- .../ContactCardsState.kt | 23 ++-- .../{PetName.kt => Nickname.kt} | 21 ++- 11 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/{PetName.kt => Nickname.kt} (66%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 26dcbe7c94..82a1de7a66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -28,7 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel -import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.PetName +import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.Nickname import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.NoteState @@ -69,21 +69,21 @@ fun observeUserName( } /** - * The nickname (NIP-85 petname) the logged-in account gave this user through - * its own contact card, decrypted from the card's content, with the card's - * tags so `:shortcode:` custom emojis resolve. Null when the account never - * nicknamed this user. Per the spec, when present it should be rendered - * instead of the user's display name. + * The nickname (NIP-85 petname + private summary) the logged-in account gave + * this user through its own contact card, decrypted from the card's content, + * with the card's tags so `:shortcode:` custom emojis resolve. Null when the + * account never nicknamed this user. Per the spec, the petname should be + * rendered instead of the user's display name. */ @Composable -fun observeUserPetName( +fun observeUserNickname( user: User, accountViewModel: AccountViewModel, -): State { +): State { val contactCards = accountViewModel.account.contactCards - val flow = remember(user) { contactCards.petNameFlow(user) } + val flow = remember(user) { contactCards.nicknameFlow(user) } - return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedPetName(user) }) + return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) }) } @OptIn(ExperimentalCoroutinesApi::class) 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 b5a61c19bb..4423e58a9c 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 @@ -60,7 +60,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -299,15 +299,16 @@ fun RenderUserAsClickableText( nav: INav, ) { val userState by observeUserInfo(baseUser, accountViewModel) - val petName by observeUserPetName(baseUser, accountViewModel) + val nickname by observeUserNickname(baseUser, accountViewModel) + val petName = nickname?.petName CreateClickableTextWithEmoji( - clickablePart = "@" + (petName?.petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), + clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()), suffix = additionalChars?.ifBlank { null }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = petName?.tags ?: userState?.tags ?: EmptyTagList, + tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, ) } 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 56ce3b6411..54e13bb3d9 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 @@ -105,7 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown @@ -1011,16 +1011,17 @@ private fun DisplayUserFromTag( nav: INav, ) { val meta by observeUserInfo(baseUser, accountViewModel) - val petName by observeUserPetName(baseUser, accountViewModel) + val nickname by observeUserNickname(baseUser, accountViewModel) + val petName = nickname?.petName CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) { Row { CreateClickableTextWithEmoji( - clickablePart = remember(meta, petName) { petName?.petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, + clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() }, maxLines = 1, route = remember(baseUser) { routeFor(baseUser) }, nav = nav, - tags = petName?.tags ?: it?.tags, + tags = if (petName != null) nickname?.tags else it?.tags, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index c36934bbb5..f8fdd1f072 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -40,7 +40,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -106,14 +106,15 @@ fun UsernameDisplay( accountViewModel: AccountViewModel, ) { val userMetadata by observeUserInfo(baseUser, accountViewModel) - val petName by observeUserPetName(baseUser, accountViewModel) + val nickname by observeUserNickname(baseUser, accountViewModel) CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) { // the account's own nickname for this user wins over the user's metadata; // its custom emojis resolve against the contact card's tags, not the profile's - val name = petName?.petName ?: it?.info?.bestName() + val petName = nickname?.petName + val name = petName ?: it?.info?.bestName() if (name != null) { - UserDisplay(name, petName?.tags ?: it?.tags, weight, fontWeight, textColor, textAlign) + UserDisplay(name, if (petName != null) nickname?.tags else it?.tags, weight, fontWeight, textColor, textAlign) } else { NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index c05a352e98..35a6fcae36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -29,7 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.FollowingIcon @@ -59,14 +59,15 @@ private fun WatchAndDisplayUser( nav: INav, ) { val userState by observeUserInfo(author, accountViewModel) - val petName by observeUserPetName(author, accountViewModel) + val nickname by observeUserNickname(author, accountViewModel) + val petName = nickname?.petName UserDisplayNameLayout( picture = { InnerUserPicture( userHex = author.pubkeyHex, userPicture = userState?.info?.picture, - userName = petName?.petName ?: userState?.info?.bestName(), + userName = petName ?: userState?.info?.bestName(), size = Size20dp, modifier = Modifier, accountViewModel = accountViewModel, @@ -83,8 +84,8 @@ private fun WatchAndDisplayUser( name = { if (userState != null) { CreateTextWithEmoji( - text = petName?.petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), - tags = petName?.tags ?: userState?.tags ?: EmptyTagList, + text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(), + tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList, maxLines = 1, fontWeight = FontWeight.Bold, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 33c17b342a..17b801c28a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPetName import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.components.util.LongPressCopyText @@ -107,17 +106,17 @@ fun DrawAdditionalInfo( val scope = rememberCoroutineScope() val identities by externalIdentities.identities.collectAsStateWithLifecycle() - val petName by observeUserPetName(baseUser, accountViewModel) - - // the nickname the account gave this user wins over the profile's own name; - // the "@name" line below keeps the real handle visible for disambiguation - val displayName = petName?.petName ?: user.info.bestName() + val displayName = user.info.bestName() val ui = accountViewModel.settings.uiSettingsFlow val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle() val showAppRecommendations by ui.showProfileAppRecommendations.collectAsStateWithLifecycle() Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = SpacedBy3dp) { + // the nickname the account gave this user, on top of (not replacing) + // the profile's own display name below + UserNicknameCard(baseUser, accountViewModel) + if (displayName != null) { Row( verticalAlignment = Alignment.CenterVertically, @@ -125,7 +124,7 @@ fun DrawAdditionalInfo( ) { CreateTextWithEmoji( text = displayName, - tags = petName?.tags ?: user.tags, + tags = user.tags, fontWeight = FontWeight.Bold, fontSize = 22.sp, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt new file mode 100644 index 0000000000..cf678ff575 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -0,0 +1,125 @@ +/* + * 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.profile.header + +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.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.compose.ui.unit.sp +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.nip85TrustedAssertions.ui.EditNicknameDialog +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname +import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +/** + * The nickname (petname) and private note the account keeps for [baseUser], + * shown above the profile's own display name without replacing it. The lock + * marks it as private — both fields live NIP-44 encrypted in the account's + * contact card. Tapping the card opens the editor. + */ +@Composable +fun UserNicknameCard( + baseUser: User, + accountViewModel: AccountViewModel, +) { + val nickname by observeUserNickname(baseUser, accountViewModel) + val card = nickname ?: return + + val isEditDialogOpen = remember { mutableStateOf(false) } + + if (isEditDialogOpen.value) { + // keeps the account's selected emoji packs loaded for the : autocomplete + WatchAndLoadMyEmojiList(accountViewModel) + EditNicknameDialog( + user = baseUser, + contactCards = accountViewModel.account.contactCards, + onSave = { petName, summary -> accountViewModel.updateContactCardPetName(baseUser, petName, summary) }, + onDismiss = { isEditDialogOpen.value = false }, + ) + } + + OutlinedCard( + onClick = { isEditDialogOpen.value = true }, + modifier = Modifier.fillMaxWidth().padding(top = 7.dp), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + card.petName?.let { + CreateTextWithEmoji( + text = it, + tags = card.tags, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + } + + if (card.petName != null && card.summary != null) { + HorizontalDivider(thickness = DividerThickness) + } + + card.summary?.let { + CreateTextWithEmoji( + text = it, + tags = card.tags, + color = MaterialTheme.colorScheme.placeholderText, + fontSize = 14.sp, + ) + } + } + + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = stringRes(R.string.nickname_private), + tint = MaterialTheme.colorScheme.placeholderText, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + .size(14.dp), + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8c0897ec31..5ec3ebfd23 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3545,6 +3545,7 @@ Edit nickname + Only visible to you Cast to device diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index f47c2204c2..e6e5477791 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -44,20 +44,22 @@ class ContactCardDecryptionCache( suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary() /** - * The petname plus the card's full decrypted tag list, so renderers can - * resolve the NIP-30 `emoji` mappings stored alongside it. + * The decrypted petname and summary plus the card's full decrypted tag list, + * so renderers can resolve the NIP-30 `emoji` mappings stored alongside them. */ - suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagList(event).toPetName() + suspend fun nickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagList(event).toNickname() /** * Synchronous variant that only reads an already-decrypted card, for use as * the immediate value of UI flows. Returns null until the suspend path has * decrypted the card once. */ - fun cachedPetNameWithEmojis(event: ContactCardEvent): PetName? = cachedPrivateCards.mergeTagListPrecached(event).toPetName() + fun cachedNickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname() - private fun TagArray.toPetName(): PetName? { - val name = petName() ?: return null - return PetName(name, toImmutableListOfLists()) + private fun TagArray.toNickname(): Nickname? { + val name = petName() + val summary = summary() + if (name == null && summary == null) return null + return Nickname(name, summary, toImmutableListOfLists()) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index 25b370002d..f643429c5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -87,22 +87,23 @@ class ContactCardsState( } /** - * The petname the account gave [target], decrypted from the card's content, - * along with the card's tags so `:shortcode:` custom emojis resolve. + * The nickname (petname + private summary) the account gave [target], + * decrypted from the card's content, along with the card's tags so + * `:shortcode:` custom emojis resolve. */ @OptIn(ExperimentalCoroutinesApi::class) - fun petNameFlow(target: User): Flow = + fun nicknameFlow(target: User): Flow = myCardFlow(target) - .mapLatest { card -> card?.let { decryptionCache.petNameWithEmojis(it) } } + .mapLatest { card -> card?.let { decryptionCache.nickname(it) } } .distinctUntilChanged() .flowOn(Dispatchers.IO) /** - * Synchronously returns the petname for [target] when its card is already + * Synchronously returns the nickname for [target] when its card is already * decrypted (or has none to decrypt). Cheap enough for initial values of UI * flows: a map read plus the decryption cache lookup, no crypto. */ - fun cachedPetName(target: User): PetName? { + fun cachedNickname(target: User): Nickname? { val card = target .cardsOrNull() @@ -110,7 +111,7 @@ class ContactCardsState( ?.value ?.get(accountUser) ?.event as? ContactCardEvent ?: return null - return decryptionCache.cachedPetNameWithEmojis(card) + return decryptionCache.cachedNickname(card) } /** @@ -121,13 +122,13 @@ class ContactCardsState( fun displayNameFlow(target: User): Flow = combine( target.metadata().flow, - petNameFlow(target), - ) { info, petName -> - petName?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex() + nicknameFlow(target), + ) { info, nickname -> + nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex() }.distinctUntilChanged() /** Synchronous first value for [displayNameFlow], from already-decrypted data. */ - fun cachedDisplayName(target: User): String = cachedPetName(target)?.petName ?: target.toBestDisplayName() + fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName() suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt similarity index 66% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt index 1cabf8e564..1d678013a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/Nickname.kt @@ -24,18 +24,25 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists /** - * The nickname the account gave a user, together with the card's decrypted tag - * list so renderers can resolve any NIP-30 `:shortcode:` custom emojis the - * petname uses (the `emoji` mappings live encrypted next to the petname). + * The decrypted private fields of the account's contact card about a user: the + * nickname (petname) and the private note (summary), plus the card's decrypted + * tag list so renderers can resolve any NIP-30 `:shortcode:` custom emojis they + * use (the `emoji` mappings live encrypted next to them). Only built when at + * least one of the two fields is present. */ @Immutable -class PetName( - val petName: String, +class Nickname( + val petName: String?, + val summary: String?, val tags: ImmutableListOfLists, ) { // content equality so flow distinctUntilChanged() dedupes re-decryptions of // the same card (ImmutableListOfLists itself compares by identity) - override fun equals(other: Any?): Boolean = other is PetName && petName == other.petName && tags.lists.contentDeepEquals(other.tags.lists) + override fun equals(other: Any?): Boolean = + other is Nickname && + petName == other.petName && + summary == other.summary && + tags.lists.contentDeepEquals(other.tags.lists) - override fun hashCode(): Int = 31 * petName.hashCode() + tags.contentHash() + override fun hashCode(): Int = 31 * (31 * petName.hashCode() + summary.hashCode()) + tags.contentHash() } From 19bbfb3be4ad3819450e1bcc477e32338ddc9276 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:37:27 +0000 Subject: [PATCH 144/206] fix: keep kind:1059 gift wraps out of the Android notifications tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop notifications redesign (ecedc4af) extracted the shared NotificationKinds.SUBSCRIPTION_KINDS list from Android's NotificationFeedFilter and rewired NOTIFICATION_KINDS to spread it. The shared list includes GiftWrapEvent.KIND (1059) because it doubles as the relay subscription filter (you must ask relays for wraps to receive NIP-17 DMs) and because Desktop renders the wrap itself as a DM inbox row. Android's display list never had 1059 before that commit, so the delegation silently started rendering wrap envelopes in the Notifications tab — with created_at randomized up to 2 days back per NIP-59, producing misordered, undecryptable rows instead of routing DMs to the chat screens. Subtract GiftWrapEvent.KIND from the Android display set, restoring the pre-extraction behavior (1059 was the only kind the delegation added). SUBSCRIPTION_KINDS keeps 1059, so the desktop relay subscription, the desktop OS-toast allow-list, and the desktop inbox DM rows are all unaffected. Android push is likewise untouched: NotificationDispatcher already excludes 1059/21059/13 and notifies on the unwrapped inner event. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV --- .../notifications/dal/NotificationFeedFilter.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 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 f10921bd06..f75d0f8c3e 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 @@ -64,6 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -126,9 +127,18 @@ class NotificationFeedFilter( // highlights, polls, videos, voice, public messages, // live-activities chat) stay here because Desktop has no // rendering for those kinds today. - com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds - .SUBSCRIPTION_KINDS - .toSet() + + // + // GiftWrap (1059) is subscription-only: the wrap is an envelope whose + // created_at is randomized up to 2 days back (NIP-59), so rendering it + // as a feed row would misorder the tab with undecryptable entries. On + // Android the unwrapped inner event (kind 14/15/…) is what notifies — + // same rule NotificationDispatcher applies to push. Desktop keeps the + // wrap in its inbox because it has no unwrap pipeline there yet. + ( + com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds + .SUBSCRIPTION_KINDS + .toSet() - GiftWrapEvent.KIND + ) + setOf( BadgeAwardEvent.KIND, GitIssueEvent.KIND, From b2456f2051ab1ec3be123a91cd62b4a0ade8422d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:42:56 +0000 Subject: [PATCH 145/206] revert: remove the 'What's using your battery' insights section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay-focused recommendations were built on a wrong premise: with the outbox model, the number of open connections and the reconnect churn are driven by the relays the user's FOLLOWS publish to, not by the user's own relay list — so 'Edit relays' steered users at a lever that doesn't control the number. Rather than ship a recommendations engine whose headline advice is misleading, the section, the UsageInsights rules, their tests, and their strings are removed. The measured data itself (rates tiles, cost cards, subsystem bars, screen time) stays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016RJ8EAsdkHx5WHHU2eQJ1P --- .../service/resourceusage/UsageInsights.kt | 164 ---------------- .../loggedIn/settings/ResourceUsageScreen.kt | 91 +-------- amethyst/src/main/res/values/strings.xml | 19 +- .../resourceusage/ResourceUsageLedgerTest.kt | 175 ------------------ 4 files changed, 2 insertions(+), 447 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt deleted file mode 100644 index c60e095cf6..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageInsights.kt +++ /dev/null @@ -1,164 +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.amethyst.service.resourceusage - -/** - * Turns a multi-day summary into at most [MAX_INSIGHTS] actionable - * recommendations, each mapping to a setting the user can actually change. - * The numbers alone make the user the analyst; these rules encode the - * analysis (thresholds informed by the 2026-07-12 ping study) and leave the - * user only the decision. - * - * Candidates that fire are ranked by an estimated-impact [score] — a rough - * conversion of each signal to "hours of extra radio/CPU activity" — so the - * cut to [MAX_INSIGHTS] drops the smallest consumers, not whichever rule - * happened to be declared last. Scores are order-of-magnitude heuristics for - * RANKING ONLY and are never shown to the user. - * - * When the measured battery split says foreground use dominates, a non-action - * [Target.FOREGROUND_INFO] note leads the list: most drain being screen-on - * time is the honest headline, and without it users would act on a minor - * insight expecting savings the settings can't deliver. - * - * Unlike [ResourceUsageAlerts] (which detects "something is wrong" and - * interrupts), insights render passively on the usage screen and use much - * lower thresholds — "worth knowing", not "pathological". - */ -object UsageInsights { - /** Each target names the settings surface that can act on the insight. */ - enum class Target { - /** Informational only — no button. Foreground/screen use dominates the measured drain. */ - FOREGROUND_INFO, - NOTIFICATION_SETTINGS, - MEDIA_SETTINGS, - RELAY_SETTINGS, - RELAY_CHURN, - POW_SETTINGS, - PUSH_PROCESSING, - PRIVACY_SETTINGS, - } - - data class Insight( - val target: Target, - /** ms for time-based insights, bytes for data, counts for relays/restarts, percent for FOREGROUND_INFO. */ - val value: Long, - /** Estimated impact in "hours of extra radio/CPU activity" — ranking only, never displayed. */ - val score: Double, - ) - - /** - * Evaluates a multi-day summary ([UsageSummary.dayCount] normalizes the - * thresholds, so a 2-day-old install is judged on 2 days, not 7). - */ - fun evaluate(s: UsageSummary): List { - val days = s.dayCount.coerceAtLeast(1) - val candidates = mutableListOf() - - // Background relay connections, attributed to the always-on service - // ONLY when its uptime actually covers most of the background window — - // a service that ran 20 minutes cannot own 30 hours of background - // connections (long background audio sessions also hold relays). - val bgConnMs = s.relayConnMsMobileBg + s.relayConnMsWifiBg - val bgWallMs = (days * ResourceUsageAccountant.DAY_MS - s.foregroundMs).coerceAtLeast(1L) - val serviceCoversBackground = s.alwaysOnMs * 2 >= bgWallMs - if (serviceCoversBackground && bgConnMs > days * BG_RELAY_HOURS_PER_DAY * MS_PER_HOUR) { - candidates += Insight(Target.NOTIFICATION_SETTINGS, bgConnMs, score = hours(s.alwaysOnMs)) - } - - // Cellular media: images/video/previews can be limited to Wi-Fi. - // Score: scattered mobile downloads pay radio ramp+tail per burst; - // ~60 MB of feed media ≈ an hour of radio-active time. - val cellularMediaBytes = - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_IMAGE] ?: 0L) + - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_VIDEO] ?: 0L) + - (s.mobileBytesPerSubsystem[UsageKeys.ROLE_PREVIEW] ?: 0L) - if (cellularMediaBytes > days * CELLULAR_MEDIA_BYTES_PER_DAY) { - candidates += Insight(Target.MEDIA_SETTINGS, cellularMediaBytes, score = cellularMediaBytes / (60.0 * 1024 * 1024)) - } - - // Average simultaneous relay connections: every open connection is - // server-pinged every 30-70s. Score: marginal — the radio is often up - // anyway; extra relays add pings, duplicates, and handshakes. - val avgRelays = s.relayConnMs / (days * ResourceUsageAccountant.DAY_MS) - if (avgRelays > AVG_RELAYS) { - candidates += Insight(Target.RELAY_SETTINGS, avgRelays, score = hours(s.relayConnMs) / 100.0) - } - - // Reconnect churn: a flaky relay in the list burns a TCP+TLS - // handshake plus a radio burst per retry. Score: ~15s of radio each. - val reconnects = s.relayConnects + s.relayConnectFails - if (reconnects > days * RECONNECTS_PER_DAY) { - candidates += Insight(Target.RELAY_CHURN, reconnects, score = reconnects * 15.0 / 3600.0) - } - - // NIP-13 mining: multi-core CPU flat out — roughly 3x the drain rate - // of an ordinary radio-active hour. - if (s.powMs > days * POW_MINUTES_PER_DAY * MS_PER_MINUTE) { - candidates += Insight(Target.POW_SETTINGS, s.powMs, score = hours(s.powMs) * 3.0) - } - - // Push processing: wakelock time plus process cold starts (each - // restart re-parses, re-connects, and pays a radio burst, ~10s each). - val pushScore = hours(s.wakelockMs) + s.appStarts * 10.0 / 3600.0 - if (s.wakelockMs > days * WAKELOCK_MINUTES_PER_DAY * MS_PER_MINUTE || s.appStarts > days * APP_STARTS_PER_DAY) { - candidates += Insight(Target.PUSH_PROCESSING, s.wakelockMs, score = pushScore) - } - - // In-app Tor: circuit crypto + keep-alives are overhead ON TOP of - // traffic that would exist anyway — count a fraction of its uptime. - if (s.torMs > days * TOR_HOURS_PER_DAY * MS_PER_HOUR) { - candidates += Insight(Target.PRIVACY_SETTINGS, s.torMs, score = hours(s.torMs) * 0.3) - } - - val ranked = candidates.sortedByDescending { it.score }.take(MAX_INSIGHTS) - - // Honesty note: when the measured split says the battery went to - // screen-on use, say so first — settings mainly reduce the background - // share, and the user deserves to know how big that share is. - val fgDominates = - s.batteryDrainFg >= MIN_DRAIN_SIGNAL_PCT && - s.batteryDrainFg >= 3 * s.batteryDrainBg.coerceAtLeast(1L) - return if (fgDominates) { - listOf(Insight(Target.FOREGROUND_INFO, s.batteryDrainFg, score = 0.0)) + ranked - } else { - ranked - } - } - - private fun hours(ms: Long): Double = ms / MS_PER_HOUR.toDouble() - - const val MAX_INSIGHTS = 3 - private const val MS_PER_HOUR = 60L * 60L * 1000L - private const val MS_PER_MINUTE = 60L * 1000L - - // Per-day thresholds — deliberately well below the alert levels. - const val BG_RELAY_HOURS_PER_DAY = 3L - const val CELLULAR_MEDIA_BYTES_PER_DAY = 20L * 1024L * 1024L - const val AVG_RELAYS = 25L - const val RECONNECTS_PER_DAY = 500L - const val POW_MINUTES_PER_DAY = 10L - const val WAKELOCK_MINUTES_PER_DAY = 5L - const val APP_STARTS_PER_DAY = 15L - const val TOR_HOURS_PER_DAY = 4L - - /** Foreground drain below this many percent points is too noisy to call a trend. */ - const val MIN_DRAIN_SIGNAL_PCT = 5L -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt index bd974db39b..4e841ad4ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ResourceUsageScreen.kt @@ -66,7 +66,6 @@ import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssem import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatBytes import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatConnHours import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageReportAssembler.Companion.formatDurationMs -import com.vitorpamplona.amethyst.service.resourceusage.UsageInsights import com.vitorpamplona.amethyst.service.resourceusage.UsageSummary import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -140,7 +139,6 @@ fun ResourceUsageScreen( TodayTiles(todaySummary) WeekRatesTiles(weekSummary) - InsightsSection(weekSummary, nav) if (weekSummary.totalBytes > 0) { SettingsSection(R.string.resource_usage_trend_section) { UsageTrendChart(loaded, today) @@ -230,93 +228,6 @@ private fun WeekRatesTiles(s: UsageSummary) { } } -/** - * Up to three plain-language recommendations, each deep-linking to the - * setting that acts on it — the rules live in [UsageInsights] so they are - * unit-testable and shared with nothing UI-bound. - */ -@Composable -private fun InsightsSection( - week: UsageSummary, - nav: INav, -) { - val insights = remember(week) { UsageInsights.evaluate(week) } - if (insights.isEmpty()) return - SettingsSection(R.string.resource_usage_insights_section) { - insights.forEachIndexed { index, insight -> - if (index > 0) SettingsDivider() - Column( - modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = insightText(insight), - style = MaterialTheme.typography.bodyMedium, - ) - val route = insightRoute(insight.target) - if (route != null) { - TextButton( - onClick = { nav.nav(route) }, - modifier = Modifier.align(Alignment.End), - ) { - Text(stringRes(insightButton(insight.target))) - } - } - } - } - } -} - -@Composable -private fun insightText(insight: UsageInsights.Insight): String = - when (insight.target) { - UsageInsights.Target.FOREGROUND_INFO -> - stringRes(R.string.resource_usage_insight_foreground, insight.value.toString()) - UsageInsights.Target.NOTIFICATION_SETTINGS -> - stringRes(R.string.resource_usage_insight_notifications, formatConnHours(insight.value)) - UsageInsights.Target.MEDIA_SETTINGS -> - stringRes(R.string.resource_usage_insight_media, formatBytes(insight.value)) - UsageInsights.Target.RELAY_SETTINGS -> { - val relays = insight.value.toInt() - pluralStringResource(R.plurals.resource_usage_insight_relays, relays, relays) - } - UsageInsights.Target.RELAY_CHURN -> { - val reconnects = insight.value.toInt() - pluralStringResource(R.plurals.resource_usage_insight_churn, reconnects, reconnects) - } - UsageInsights.Target.POW_SETTINGS -> - stringRes(R.string.resource_usage_insight_pow, formatDurationMs(insight.value)) - UsageInsights.Target.PUSH_PROCESSING -> - stringRes(R.string.resource_usage_insight_push, formatDurationMs(insight.value)) - UsageInsights.Target.PRIVACY_SETTINGS -> - stringRes(R.string.resource_usage_insight_tor, formatDurationMs(insight.value)) - } - -private fun insightRoute(target: UsageInsights.Target): Route? = - when (target) { - UsageInsights.Target.FOREGROUND_INFO -> null - UsageInsights.Target.NOTIFICATION_SETTINGS -> Route.NotificationSettings - UsageInsights.Target.MEDIA_SETTINGS -> Route.Settings - UsageInsights.Target.RELAY_SETTINGS -> Route.EditRelays - UsageInsights.Target.RELAY_CHURN -> Route.EditRelays - UsageInsights.Target.POW_SETTINGS -> Route.ComposeSettings - UsageInsights.Target.PUSH_PROCESSING -> Route.NotificationSettings - UsageInsights.Target.PRIVACY_SETTINGS -> Route.PrivacyOptions - } - -@StringRes -private fun insightButton(target: UsageInsights.Target): Int = - when (target) { - UsageInsights.Target.FOREGROUND_INFO -> R.string.resource_usage_insights_section - UsageInsights.Target.NOTIFICATION_SETTINGS -> R.string.resource_usage_alwayson_settings_button - UsageInsights.Target.MEDIA_SETTINGS -> R.string.resource_usage_insight_button_media - UsageInsights.Target.RELAY_SETTINGS -> R.string.resource_usage_insight_button_relays - UsageInsights.Target.RELAY_CHURN -> R.string.resource_usage_insight_button_relays - UsageInsights.Target.POW_SETTINGS -> R.string.compose_settings - UsageInsights.Target.PUSH_PROCESSING -> R.string.resource_usage_alwayson_settings_button - UsageInsights.Target.PRIVACY_SETTINGS -> R.string.resource_usage_insight_button_privacy - } - /** * Ranked per-feature traffic with proportion bars (7 days). Cellular is the * scarce resource (battery and often money), so when any cellular traffic @@ -652,7 +563,7 @@ private fun TorServiceSection( onClick = { nav.nav(Route.PrivacyOptions) }, modifier = Modifier.align(Alignment.End), ) { - Text(stringRes(R.string.resource_usage_insight_button_privacy)) + Text(stringRes(R.string.resource_usage_tor_settings_button)) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5067aaf011..5be4510e0f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3227,24 +3227,7 @@ Relay connections held while closed Battery drained meanwhile (whole device) Change notification settings - What’s using your battery - The always-on notification service held relay connections open for %1$s while the app was in the background. - Most of your measured battery use (%1$s%%) happened while you were actively using the app — screen and browsing, not background activity. Settings changes mainly reduce the background share. - - Relays reconnected %1$d time. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. - Relays reconnected %1$d times. Frequent reconnections usually mean an unreliable relay in your list — each retry pays a new handshake and a radio wake-up. - - Proof-of-work mining ran the processor at full speed for %1$s. Lowering the default difficulty reduces this directly. - Processing incoming notifications kept the device awake for %1$s. Changing how notifications are delivered can reduce it. - %1$s of images, video, and previews were downloaded over cellular. Media loading can be limited to Wi-Fi. - - The app kept %1$d relay connection open on average. Each open connection keeps the radio awake — fewer relays means longer battery. - The app kept %1$d relay connections open on average. Each open connection keeps the radio awake — fewer relays means longer battery. - - Built-in Tor ran for %1$s. Tor spends extra battery on encryption and keep-alives for the same traffic. - Media settings - Edit relays - Privacy options + Privacy options Battery / hour in app Data / hour in app Avg. relay connections diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt index e972982b3d..6be24d2942 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageLedgerTest.kt @@ -662,181 +662,6 @@ class ScreenTimeIntegratorTest { } } -class UsageInsightsTest { - private fun summary( - counters: Map, - days: Int, - ) = UsageSummary.fromDays(List(days) { if (it == 0) counters else emptyMap() }) - - @Test - fun quietWeekYieldsNoInsights() { - val s = summary(mapOf(UsageKeys.APP_FG_MS to 3_600_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).isEmpty()) - } - - @Test - fun backgroundRelayTimeWithAlwaysOnSuggestsNotificationSettings() { - // Service ran 100 of the ~168 background hours: it owns the connections. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 100L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights.first().target) - } - - @Test - fun briefServiceUptimeIsNotBlamedForBackgroundConnectionsItCannotOwn() { - // Service ran 1h all week; 28h of background connections came from - // something else (e.g. background audio) — no notification insight. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 1L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - ), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) - } - - @Test - fun insightsAreRankedByEstimatedImpactNotDeclarationOrder() { - // PoW at 100h scores ~300; the notification insight scores ~150 — - // PoW must come first even though its rule is declared later. - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 150L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L, - UsageKeys.POW_MS to 100L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.POW_SETTINGS, insights[0].target) - assertEquals(UsageInsights.Target.NOTIFICATION_SETTINGS, insights[1].target) - } - - @Test - fun powMiningTimeSuggestsComposeSettings() { - val s = summary(mapOf(UsageKeys.POW_MS to 7L * 15L * 60_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.POW_SETTINGS }) - } - - @Test - fun reconnectChurnSuggestsReviewingTheRelayList() { - val s = summary(mapOf(UsageKeys.relayConnects(mobile = true, foreground = false) to 7L * 600L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_CHURN }) - } - - @Test - fun pushProcessingTimeSuggestsNotificationSettings() { - val s = summary(mapOf(UsageKeys.WAKELOCK_NOTIF_MS to 7L * 10L * 60_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PUSH_PROCESSING }) - } - - @Test - fun foregroundDominatedDrainLeadsWithTheHonestyNote() { - val s = - summary( - mapOf( - UsageKeys.BATTERY_DRAIN_FG to 20L, - UsageKeys.BATTERY_DRAIN_BG to 2L, - UsageKeys.TOR_MS to 7L * 5L * 3_600_000L, - ), - days = 7, - ) - val insights = UsageInsights.evaluate(s) - assertEquals(UsageInsights.Target.FOREGROUND_INFO, insights.first().target) - assertTrue(insights.any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun balancedDrainDoesNotAddTheHonestyNote() { - val s = - summary( - mapOf( - UsageKeys.BATTERY_DRAIN_FG to 10L, - UsageKeys.BATTERY_DRAIN_BG to 8L, - ), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.FOREGROUND_INFO }) - } - - @Test - fun backgroundRelayTimeWithoutAlwaysOnDoesNotBlameNotifications() { - val s = - summary( - mapOf(UsageKeys.relayConnMs(mobile = true, foreground = false) to 7L * 4L * 3_600_000L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).none { it.target == UsageInsights.Target.NOTIFICATION_SETTINGS }) - } - - @Test - fun cellularMediaSuggestsMediaSettingsButWifiDoesNot() { - val cellular = - summary( - mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = true, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), - days = 7, - ) - assertEquals(UsageInsights.Target.MEDIA_SETTINGS, UsageInsights.evaluate(cellular).first().target) - - val wifi = - summary( - mapOf(UsageKeys.net(UsageKeys.ROLE_IMAGE, mobile = false, foreground = true, received = true) to 7L * 30L * 1024L * 1024L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(wifi).isEmpty()) - } - - @Test - fun manySimultaneousRelaysSuggestsEditingTheRelayList() { - // 40 relays open around the clock for a week. - val s = - summary( - mapOf(UsageKeys.relayConnMs(mobile = false, foreground = true) to 40L * 7L * 24L * 3_600_000L), - days = 7, - ) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.RELAY_SETTINGS }) - } - - @Test - fun longTorUptimeSuggestsPrivacyOptions() { - val s = summary(mapOf(UsageKeys.TOR_MS to 7L * 5L * 3_600_000L), days = 7) - assertTrue(UsageInsights.evaluate(s).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun thresholdsScaleWithTheNumberOfObservedDays() { - // 5 tor-hours looks fine over a week but heavy over a single day. - val counters = mapOf(UsageKeys.TOR_MS to 5L * 3_600_000L) - assertTrue(UsageInsights.evaluate(summary(counters, days = 7)).isEmpty()) - assertTrue(UsageInsights.evaluate(summary(counters, days = 1)).any { it.target == UsageInsights.Target.PRIVACY_SETTINGS }) - } - - @Test - fun neverMoreThanThreeInsights() { - val s = - summary( - mapOf( - UsageKeys.ALWAYS_ON_MS to 168L * 3_600_000L, - UsageKeys.relayConnMs(mobile = true, foreground = false) to 40L * 7L * 24L * 3_600_000L, - UsageKeys.net(UsageKeys.ROLE_VIDEO, mobile = true, foreground = true, received = true) to 7L * 200L * 1024L * 1024L, - UsageKeys.TOR_MS to 7L * 10L * 3_600_000L, - ), - days = 7, - ) - assertEquals(UsageInsights.MAX_INSIGHTS, UsageInsights.evaluate(s).size) - } -} - class UsageSummaryMapsTest { @Test fun screenTimeAndCellularMapsAreExtractedFromCounters() { From d2f02fbd321d476ffec040dfcce73b69a5a59946 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 23:52:13 +0000 Subject: [PATCH 146/206] style: double the nickname card's top margin, halve its bottom margin Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY --- .../ui/screen/loggedIn/profile/header/UserNicknameCard.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt index cf678ff575..a09f1d78bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/UserNicknameCard.kt @@ -80,7 +80,7 @@ fun UserNicknameCard( OutlinedCard( onClick = { isEditDialogOpen.value = true }, - modifier = Modifier.fillMaxWidth().padding(top = 7.dp), + modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 3.5.dp), ) { Box(modifier = Modifier.fillMaxWidth()) { Column( From 7a30c185908bb96cf606444885530cf0d6e8fc2f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 00:19:01 +0000 Subject: [PATCH 147/206] refactor: decouple Android NOTIFICATION_KINDS from desktop's subscription list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android never uses NotificationKinds.SUBSCRIPTION_KINDS for relay subscriptions — every kind in it already arrives via Android's own datasources (FilterNotificationsToPubkey, the chat datasources, the wallet assembler) or via local unwrapping (14/15). Spreading it into NOTIFICATION_KINDS only coupled Android's display gate to a list whose job is desktop's relay-filter/toast allow-list, which is exactly how the kind-1059 wrap leaked into the Android notifications tab. Restore NOTIFICATION_KINDS as an explicit flat set (identical content to the previous commit's subtraction) and add NotificationKindsContractTest as the drift tripwire: envelope kinds (1059/21059) must never render on the Android tab, and every kind desktop notifies on must be either displayable on Android or a known envelope. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011A6iSrJJHUoz686eDnCQMV --- .../dal/NotificationFeedFilter.kt | 80 ++++++++++--------- .../dal/NotificationKindsContractTest.kt | 76 ++++++++++++++++++ 2 files changed, 119 insertions(+), 37 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt 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 f75d0f8c3e..124e578ad0 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 @@ -64,7 +64,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent -import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent @@ -80,6 +80,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent 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.nipF4Podcasts.episode.PodcastEpisodeEvent import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import kotlinx.coroutines.flow.MutableStateFlow @@ -120,42 +121,47 @@ class NotificationFeedFilter( ) val NOTIFICATION_KINDS = - // The core subscription kinds are shared with Desktop through - // `commons/.../moderation/notifications/NotificationKinds.SUBSCRIPTION_KINDS` - // so a change on either platform automatically propagates. - // Android-only extras (badge awards, git issues/patches/PRs, - // highlights, polls, videos, voice, public messages, - // live-activities chat) stay here because Desktop has no - // rendering for those kinds today. - // - // GiftWrap (1059) is subscription-only: the wrap is an envelope whose - // created_at is randomized up to 2 days back (NIP-59), so rendering it - // as a feed row would misorder the tab with undecryptable entries. On - // Android the unwrapped inner event (kind 14/15/…) is what notifies — - // same rule NotificationDispatcher applies to push. Desktop keeps the - // wrap in its inbox because it has no unwrap pipeline there yet. - ( - com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds - .SUBSCRIPTION_KINDS - .toSet() - GiftWrapEvent.KIND - ) + - setOf( - BadgeAwardEvent.KIND, - GitIssueEvent.KIND, - GitPatchEvent.KIND, - GitPullRequestEvent.KIND, - GitPullRequestUpdateEvent.KIND, - HighlightEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - PictureEvent.KIND, - PollEvent.KIND, - ZapPollEvent.KIND, - PublicMessageEvent.KIND, - VideoNormalEvent.KIND, - VideoShortEvent.KIND, - VoiceEvent.KIND, - VoiceReplyEvent.KIND, - ) + ADDRESSABLE_KINDS + // Kinds that RENDER as a row on the Notifications tab. This is a + // display gate over whatever is already in LocalCache — it plays no + // part in relay subscriptions (those live in + // FilterNotificationsToPubkey, the chat datasources, and the wallet + // assembler). It deliberately does NOT share Desktop's + // `NotificationKinds.SUBSCRIPTION_KINDS`: that list answers "what to + // ask relays for / toast on" and includes envelope kinds like + // GiftWrap (1059), whose created_at is randomized up to 2 days back + // (NIP-59). Envelopes never render here — the unwrapped inner event + // (kind 14/15/…) is the feed row, same rule NotificationDispatcher + // applies to push. NotificationKindsContractTest pins the + // relationship between the two lists. + setOf( + BadgeAwardEvent.KIND, + ChannelMessageEvent.KIND, + ChatMessageEvent.KIND, + ChatMessageEncryptedFileHeaderEvent.KIND, + CommentEvent.KIND, + GenericRepostEvent.KIND, + GitIssueEvent.KIND, + GitPatchEvent.KIND, + GitPullRequestEvent.KIND, + GitPullRequestUpdateEvent.KIND, + HighlightEvent.KIND, + TextNoteEvent.KIND, + ReactionEvent.KIND, + RepostEvent.KIND, + LnZapEvent.KIND, + NutzapEvent.KIND, + OnchainZapEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + PictureEvent.KIND, + PollEvent.KIND, + ZapPollEvent.KIND, + PrivateDmEvent.KIND, + PublicMessageEvent.KIND, + VideoNormalEvent.KIND, + VideoShortEvent.KIND, + VoiceEvent.KIND, + VoiceReplyEvent.KIND, + ) + ADDRESSABLE_KINDS // How deep to walk a public chat reply chain looking for one of the // user's own messages. Bounds the cost on very long threads; the diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt new file mode 100644 index 0000000000..0e49022cf9 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.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.amethyst.ui.screen.loggedIn.notifications.dal + +import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the relationship between the two independently-maintained kind lists: + * + * - `NotificationKinds.SUBSCRIPTION_KINDS` (commons) — what Desktop asks + * relays for and toasts on. May contain ENVELOPE kinds (gift wraps) whose + * created_at is randomized per NIP-59, because Desktop surfaces the wrap + * itself as a DM row. + * - `NotificationFeedFilter.NOTIFICATION_KINDS` (Android) — what renders as + * a row on the Notifications tab. Envelopes must never appear here; the + * unwrapped inner event is the row. + * + * The lists were briefly coupled (NOTIFICATION_KINDS spread + * SUBSCRIPTION_KINDS), which silently pulled the kind-1059 wrap into the + * Android feed. They are now maintained separately; this test is the tripwire + * that keeps them from drifting apart unintentionally in either direction. + */ +class NotificationKindsContractTest { + private val envelopeKinds = setOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND) + + @Test + fun `envelope kinds never render on the Android notifications tab`() { + val leaked = envelopeKinds.intersect(NotificationFeedFilter.NOTIFICATION_KINDS.toSet()) + assertTrue( + "Envelope kinds $leaked are in NOTIFICATION_KINDS. Wraps have a " + + "randomized created_at (NIP-59) and no decryptable payload to render — " + + "the unwrapped inner event is the feed row. If a new envelope kind is " + + "intentional, unwrap it instead of displaying it.", + leaked.isEmpty(), + ) + } + + @Test + fun `every kind desktop notifies on is displayable on Android or a known envelope`() { + val unaccounted = + NotificationKinds.SUBSCRIPTION_KINDS.toSet() - + NotificationFeedFilter.NOTIFICATION_KINDS.toSet() - + envelopeKinds + + assertTrue( + "Kinds $unaccounted were added to the shared SUBSCRIPTION_KINDS but are " + + "neither displayable on the Android notifications tab nor a known " + + "envelope kind. Either add them to NOTIFICATION_KINDS (if Android " + + "should render them) or to envelopeKinds in this test (if they only " + + "deliver an inner payload).", + unaccounted.isEmpty(), + ) + } +} From a335c0387c01645f2436da2a6f689c496cc72399 Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:39:07 +0000 Subject: [PATCH 148/206] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-ar-rSA/strings.xml | 1 + amethyst/src/main/res/values-bn-rBD/strings.xml | 1 + amethyst/src/main/res/values-cs/strings.xml | 1 + amethyst/src/main/res/values-de-rDE/strings.xml | 1 + amethyst/src/main/res/values-el-rGR/strings.xml | 1 + amethyst/src/main/res/values-en-rGB/strings.xml | 1 + amethyst/src/main/res/values-eo-rUY/strings.xml | 1 + amethyst/src/main/res/values-es-rES/strings.xml | 1 + amethyst/src/main/res/values-es-rMX/strings.xml | 1 + amethyst/src/main/res/values-es-rUS/strings.xml | 1 + amethyst/src/main/res/values-fa-rIR/strings.xml | 1 + amethyst/src/main/res/values-fi-rFI/strings.xml | 1 + amethyst/src/main/res/values-fr-rCA/strings.xml | 1 + amethyst/src/main/res/values-fr-rFR/strings.xml | 1 + amethyst/src/main/res/values-hi-rIN/strings.xml | 1 + amethyst/src/main/res/values-hu-rHU/strings.xml | 1 + amethyst/src/main/res/values-in-rID/strings.xml | 1 + amethyst/src/main/res/values-it-rIT/strings.xml | 1 + amethyst/src/main/res/values-ja-rJP/strings.xml | 1 + amethyst/src/main/res/values-ko-rKR/strings.xml | 1 + amethyst/src/main/res/values-lv-rLV/strings.xml | 1 + amethyst/src/main/res/values-nl-rNL/strings.xml | 1 + amethyst/src/main/res/values-pl-rPL/strings.xml | 1 + amethyst/src/main/res/values-pt-rBR/strings.xml | 1 + amethyst/src/main/res/values-pt-rPT/strings.xml | 1 + amethyst/src/main/res/values-ru-rRU/strings.xml | 1 + amethyst/src/main/res/values-ru-rUA/strings.xml | 1 + amethyst/src/main/res/values-sl-rSI/strings.xml | 1 + amethyst/src/main/res/values-sr-rSP/strings.xml | 1 + amethyst/src/main/res/values-sv-rSE/strings.xml | 1 + amethyst/src/main/res/values-sw/strings.xml | 1 + amethyst/src/main/res/values-ta-rIN/strings.xml | 1 + amethyst/src/main/res/values-th-rTH/strings.xml | 1 + amethyst/src/main/res/values-tr-rTR/strings.xml | 1 + amethyst/src/main/res/values-uk-rUA/strings.xml | 1 + amethyst/src/main/res/values-uz-rUZ/strings.xml | 1 + amethyst/src/main/res/values-vi-rVN/strings.xml | 1 + amethyst/src/main/res/values-zh-rCN/strings.xml | 3 +++ amethyst/src/main/res/values-zh-rHK/strings.xml | 1 + amethyst/src/main/res/values-zh-rSG/strings.xml | 1 + amethyst/src/main/res/values-zh-rTW/strings.xml | 1 + 41 files changed, 43 insertions(+) diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index fe16a886ea..920e74aecc 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -2823,6 +2823,7 @@ إجراءات الوسائط التشغيل تلقائي + البث إلى جهاز إيقاف البث diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index edc69f1593..399854cf01 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -2702,6 +2702,7 @@ মিডিয়া অ্যাকশন প্লেব্যাক স্বয়ংক্রিয় + ডিভাইসে কাস্ট করুন কাস্টিং বন্ধ করুন diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f9a50c85e7..706a9fdc4b 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -3301,6 +3301,7 @@ Akce médií Přehrávání Auto + Odeslat do zařízení Ukončit odesílání diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 090be920d0..c8903d7063 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -3169,6 +3169,7 @@ Medienaktionen Wiedergabe Auto + An Gerät streamen Streaming beenden diff --git a/amethyst/src/main/res/values-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index e989bff3a5..2844d47390 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -2647,6 +2647,7 @@ Ενέργειες Πολυμέσων Αναπαραγωγή Αυτόματο + Μετάδοση σε συσκευή Διακοπή μετάδοσης diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 132d63dc73..eda9fdf794 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -30,6 +30,7 @@ + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index ce414fabc8..418d68e31c 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -2704,6 +2704,7 @@ Mediaj Agoj Reproduktado Aŭtomata + Transigi al aparato Ĉesi transiĝon diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 4243cf8549..b8ce45f477 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2770,6 +2770,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 6df3bf7d0f..d57b90ddfe 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2761,6 +2761,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 05a196bd7d..d83d1cbde8 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2761,6 +2761,7 @@ Acciones de medios Reproducción Automático + Emitir al dispositivo Detener emisión diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 10dad02bfb..8ffe8921b4 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2718,6 +2718,7 @@ اقدامات رسانه پخش خودکار + پخش روی دستگاه توقف پخش diff --git a/amethyst/src/main/res/values-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 7ce8e75691..c2256d196a 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -2678,6 +2678,7 @@ Median toiminnot Toisto Automaattinen + Suoratoista laitteeseen Lopeta suoratoisto diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 9a98dbfd52..5cfeafd164 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -2606,6 +2606,7 @@ Actions sur le profil Actions sur le média Lecture + Diffuser sur l\'appareil Arrêter la diffusion diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5caec58f87..89536d9bac 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2983,6 +2983,7 @@ Actions sur le média Lecture Auto + Diffuser sur l\'appareil Arrêter la diffusion diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 90d73c13a3..e096ff2974 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -3216,6 +3216,7 @@ अभिलेख कार्य चालन स्वचालित + यन्त्र पर निक्षेपण निक्षेपण रोकें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c667c9f116..c451b2e909 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -3217,6 +3217,7 @@ Médiaműveletek Lejátszás Automatikus + Közvetítés eszközre Közvetítés leállítása diff --git a/amethyst/src/main/res/values-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 55c2791a6f..866eac29d9 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -2606,6 +2606,7 @@ Seharusnya %3$s Tindakan Media Pemutaran Otomatis + Tampilkan ke perangkat Hentikan penayangan diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index 11279feaef..f0d0a2a7b5 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -2645,6 +2645,7 @@ Azioni profilo Azioni media Riproduzione + Trasmetti al dispositivo Interrompi trasmissione diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 033213ceb3..5d772688c6 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -2660,6 +2660,7 @@ メディアのアクション 再生 自動 + デバイスにキャスト キャストを停止 diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index d513de6d20..2c350714e7 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -2647,6 +2647,7 @@ 미디어 작업 재생 자동 + 기기로 캐스트 캐스트 중지 diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index 567924d2d6..947c7a7461 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -2720,6 +2720,7 @@ Multivides darbības Atskaņošana Automātiski + Apraidīt uz ierīci Pārtraukt apraidi diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 48933a0fb3..88635bb0a0 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -3052,6 +3052,7 @@ Media-acties Afspelen Auto + Casten naar apparaat Casten stoppen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cf336b07d8..9bc2d19653 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -3302,6 +3302,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Akcje multimediów Odtwarzanie Automatycznie + Zrzuć na urządzenie Przestań zrzucać diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2a7f29143b..8f23756f3a 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -3165,6 +3165,7 @@ Ações de mídia Reprodução Auto + Transmitir para dispositivo Parar transmissão diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index d06e8cbdb8..77946bedcb 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -2664,6 +2664,7 @@ Ações do perfil Ações de mídia Reprodução + Transmitir para dispositivo Parar transmissão diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 541080ba25..ce0893cfb8 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -2742,6 +2742,7 @@ Действия с медиа Воспроизведение Авто + Трансляция на устройство Остановить трансляцию diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 63c1339889..9f41ce3df0 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -2725,6 +2725,7 @@ Действия с медиа Воспроизведение Авто + Трансляция на устройство Остановить трансляцию diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7470dcfef9..7dd53b26e3 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -3317,6 +3317,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Možnosti predstavnosti Predvajaj Samodejno + Prenesi v napravo Prenehaj s prenašanjem diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index b068cb0758..49b7601369 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -2703,6 +2703,7 @@ Radnje sa medijima Reprodukcija Automatski + Prikaži na uređaju Zaustavi prikazivanje diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 83a1991f8d..6bbc9cff50 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -3173,6 +3173,7 @@ Medieåtgärder Uppspelning Auto + Casta till enhet Sluta casta diff --git a/amethyst/src/main/res/values-sw/strings.xml b/amethyst/src/main/res/values-sw/strings.xml index fda33de346..c1758eb485 100644 --- a/amethyst/src/main/res/values-sw/strings.xml +++ b/amethyst/src/main/res/values-sw/strings.xml @@ -2684,6 +2684,7 @@ Vitendo vya Midia Uchezaji Otomatiki + Tuma kwenye kifaa Acha kutuma diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index ac6c3e80b9..e77b264334 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -2673,6 +2673,7 @@ ஊடக செயல்கள் இயக்கம் தானியங்கி + சாதனத்திற்கு அனுப்பவும் அனுப்புவதை நிறுத்தவும் diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 7be1e93544..dcb597e18d 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2512,6 +2512,7 @@ การดำเนินการกับสื่อ การเล่น อัตโนมัติ + แคสต์ไปยังอุปกรณ์ หยุดการแคสต์ diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index b3b34cb392..c1ebcd1dff 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -2685,6 +2685,7 @@ Medya İşlemleri Oynatma Otomatik + Cihaza yayınla Yayını durdur diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index fe1cde2fcf..b6211b0f0d 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -2733,6 +2733,7 @@ Дії з медіа Відтворення Авто + Трансляція на пристрій Зупинити трансляцію diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 5e41ccb79e..6fe092d19e 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -2689,6 +2689,7 @@ Media amallar Ijro etish Avtomatik + Qurilmaga uzatish Uzatishni to\'xtatish diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index d153949ae2..9e72b7f0ad 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -2630,6 +2630,7 @@ Thao tác phương tiện Phát lại Tự động + Chiếu đến thiết bị Dừng chiếu diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3ff2b4c951..3e56dab2f5 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -1184,6 +1184,8 @@ 尚无接收方:只有您才能看到此笔记。添加分享对象。 添加 从通知中删除用户 + 通知中。轻触静音此用户的通知 + 已静音。轻触再次通知此用户 搜索并添加要通知的用户 在这里不是书签 从列表中删除书签 @@ -3172,6 +3174,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index fea511090e..21cb66c936 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2700,6 +2700,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 4d2541c844..65bb95dfd3 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2700,6 +2700,7 @@ 媒体操作 播放 自动 + 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 8feb85b6b3..98a024b0ee 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2665,6 +2665,7 @@ 媒體操作 播放 自動 + 投放至裝置 停止投放 From 86da29fa788522451b753c9108a5c0d486ba6419 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 01:26:33 +0000 Subject: [PATCH 149/206] fix(ui): mute the header pill wash, restore dotted timestamps, tighten menu gap - HeaderPill's secondary-container background pulled too much attention; it now uses a faint onSurface wash (7%) with placeholderText content, so pills read as tappable metadata without competing with the note. - Note-header timestamps go back to the original dotted format at the default size; the TimeAgo style/fontSize params are reverted. - The timestamp + more-options pair renders unspaced again (the dot and the button's icon inset provide the separation), fixing the oversized gap the row-level spacedBy introduced before the 3-dot menu. - The header preview now consumes its fabricated events as already-verified (they cannot pass id/sig checks, which left every row bare), gives the repost a parseable inner event, and fetches the draft through its AddressableNote (draft wraps are addressable events). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 16 +++++----- .../note/elements/NoteHeaderMarkersPreview.kt | 30 +++++++++++-------- .../amethyst/ui/note/elements/TimeAgo.kt | 17 +++-------- .../loggedIn/threadview/ThreadFeedView.kt | 10 ++++--- .../amethyst/commons/ui/note/HeaderPill.kt | 15 ++++++---- 5 files changed, 46 insertions(+), 42 deletions(-) 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 579844d990..88b1c511eb 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 @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -207,7 +206,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDi import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -1898,12 +1896,16 @@ fun FirstUserInfoRow( JumpToParentReplyButton(baseNote, accountViewModel, nav) - TimeAgo(baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) + // The dotted TimeAgo carries its own " • " separator and the options + // button has slack around its icon, so this pair stays unspaced. + Row(verticalAlignment = CenterVertically) { + TimeAgo(baseNote) - if (moreOptions == null) { - MoreOptionsButton(baseNote, editState, accountViewModel, nav) - } else { - moreOptions() + if (moreOptions == null) { + MoreOptionsButton(baseNote, editState, accountViewModel, nav) + } else { + moreOptions() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 02438cf59a..432e585803 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -110,30 +110,36 @@ fun NoteHeaderFirstRowDensityPreview() { val geoPowExpiring: Note val kitchenSink: Note + val plainEvent = TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x") + + // Drafts are addressable: consumption lands on the AddressableNote, so the + // preview must fetch it by address rather than by event id. + val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume( MetadataEvent(AUTHOR_METADATA_ID, AUTHOR, now, emptyArray(), """{"name":"Vitor"}""", "x"), null, - false, + true, ) LocalCache.justConsume( MetadataEvent(LONG_NAME_METADATA_ID, LONG_NAME_AUTHOR, now, emptyArray(), """{"name":"A user with a very long display name"}""", "x"), null, - false, + true, ) - LocalCache.justConsume(TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x"), null, false) - LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, false) - LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), "", "x"), null, false) + LocalCache.justConsume(plainEvent, null, true) + LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, true) + LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), plainEvent.toJson(), "x"), null, true) // An empty sig is what marks a note as a private rumor. - LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, false) - LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, false) - LocalCache.justConsume(DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, emptyArray(), "", "x"), null, false) + LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, true) + LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, true) + LocalCache.justConsume(draftEvent, null, true) LocalCache.justConsume( TextNoteEvent(COMMUNITY_POST_ID, AUTHOR, now - 300, arrayOf(arrayOf("a", COMMUNITY_ADDRESS)), "hello community", "x"), null, - false, + true, ) LocalCache.justConsume( TextNoteEvent( @@ -149,7 +155,7 @@ fun NoteHeaderFirstRowDensityPreview() { "x", ), null, - false, + true, ) LocalCache.justConsume( TextNoteEvent( @@ -168,7 +174,7 @@ fun NoteHeaderFirstRowDensityPreview() { "", ), null, - false, + true, ) plain = LocalCache.getOrCreateNote(PLAIN_ID) @@ -176,7 +182,7 @@ fun NoteHeaderFirstRowDensityPreview() { rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) edited = LocalCache.getOrCreateNote(EDITED_ID) editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) - draft = LocalCache.getOrCreateNote(DRAFT_ID) + draft = LocalCache.getOrCreateAddressableNote(draftEvent.address()) communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index af77ed2b82..c23fd2d41c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -125,25 +125,16 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo( - note: Note, - style: TimeAgoStyle = TimeAgoStyle.Dotted, - fontSize: TextUnit = TextUnit.Unspecified, -) { +fun TimeAgo(note: Note) { val time = note.createdAt() ?: return - TimeAgo(time, style, fontSize) + TimeAgo(time) } @Composable -fun TimeAgo( - time: Long, - style: TimeAgoStyle = TimeAgoStyle.Dotted, - fontSize: TextUnit = TextUnit.Unspecified, -) { +fun TimeAgo(time: Long) { ToggleableTimeAgoText( timestamp = time, - style = style, - fontSize = fontSize, + style = TimeAgoStyle.Dotted, color = MaterialTheme.colorScheme.placeholderText, ) } 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 11c461fee7..f2762f30bf 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 @@ -127,7 +127,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -232,7 +231,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -743,9 +741,13 @@ private fun FullBleedNoteCompose( Expiration(baseNote) - TimeAgo(note = baseNote, style = TimeAgoStyle.Short, fontSize = Font12SP) + // The dotted TimeAgo carries its own " • " separator and the + // options button has slack around its icon: keep the pair unspaced. + Row(verticalAlignment = Alignment.CenterVertically) { + TimeAgo(note = baseNote) - MoreOptionsButton(baseNote, editState, accountViewModel, nav) + MoreOptionsButton(baseNote, editState, accountViewModel, nav) + } } Row( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 6c4dc4726b..312ca0d584 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -37,12 +37,14 @@ 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.MaterialSymbol +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText /** * Compact squared chip for note-header metadata (PoW, OTS, location, relay - * hosts, ...). Matches the relay information pills used in the NIP-29 chat - * headers so all header badges share one look: a small icon plus a short - * label on a secondary-container surface with slightly rounded corners. + * hosts, ...) so all header badges share one look: a small icon plus a short + * label on a faint surface tint with slightly rounded corners. The wash is + * deliberately subtle — just enough of an outline to read as tappable + * metadata without competing with the note content. */ @Composable fun HeaderPill( @@ -55,7 +57,8 @@ fun HeaderPill( ) { Surface( shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + contentColor = MaterialTheme.colorScheme.placeholderText, modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, ) { Row( @@ -66,13 +69,13 @@ fun HeaderPill( Icon( symbol = symbol, contentDescription = contentDescription, - tint = iconTint ?: MaterialTheme.colorScheme.onSecondaryContainer, + tint = iconTint ?: MaterialTheme.colorScheme.placeholderText, modifier = Modifier.size(11.dp), ) Text( text = text, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, overflow = TextOverflow.Ellipsis, ) From efae1dec22023378e486c67bed7b402f848cbc4b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 08:21:44 -0400 Subject: [PATCH 150/206] Adjustments --- .../amethyst/ui/note/NoteCompose.kt | 42 +++++++++---------- .../note/elements/NoteHeaderMarkersPreview.kt | 31 +++++--------- 2 files changed, 32 insertions(+), 41 deletions(-) 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 88b1c511eb..041d3b37ff 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 @@ -1834,6 +1834,16 @@ fun FirstUserInfoRow( NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } + if (isRepost) { + BoostedMark() + } + + CheckAndDisplayEditStatus(editState) + + if (isDraft) { + DisplayDraft() + } + if (isDraft) { ObserveDraftEvent(baseNote, accountViewModel) { draftNote -> val isCommunityPost = @@ -1860,16 +1870,22 @@ fun FirstUserInfoRow( } } - if (isRepost) { - BoostedMark() + val noteEvent = baseNote.event + + val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } + if (geo != null) { + DisplayLocation(geo, accountViewModel, nav) } - CheckAndDisplayEditStatus(editState) + DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) - if (isDraft) { - DisplayDraft() + val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } + if (pow != null) { + DisplayPoW(pow) } + Expiration(baseNote) + if (baseNote.isPrivateRumor()) { PrivateRumorMark() } @@ -1878,22 +1894,6 @@ fun FirstUserInfoRow( PinnedMark() } - val noteEvent = baseNote.event - - val geo = remember(noteEvent) { noteEvent?.geoHashOrScope() } - if (geo != null) { - DisplayLocation(geo, accountViewModel, nav) - } - - val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } - if (pow != null) { - DisplayPoW(pow) - } - - DisplayOtsIfInOriginal(baseNote, editState, accountViewModel) - - Expiration(baseNote) - JumpToParentReplyButton(baseNote, accountViewModel, nav) // The dotted TimeAgo carries its own " • " separator and the options diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 432e585803..5ea22ae855 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction.repost import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache @@ -100,22 +101,22 @@ fun NoteHeaderFirstRowDensityPreview() { val now = TimeUtils.now() val expiresAt = (now + 7200).toString() - val plain: Note - val repost: Note - val rumorPinned: Note - val edited: Note - val editVersion: Note - val draft: Note - val communityPost: Note - val geoPowExpiring: Note - val kitchenSink: Note - val plainEvent = TextNoteEvent(PLAIN_ID, AUTHOR, now - 300, emptyArray(), "GM", "x") // Drafts are addressable: consumption lands on the AddressableNote, so the // preview must fetch it by address rather than by event id. val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") + val plain: Note = LocalCache.getOrCreateNote(PLAIN_ID) + val repost: Note = LocalCache.getOrCreateNote(REPOST_ID) + val rumorPinned: Note = LocalCache.getOrCreateNote(RUMOR_ID) + val edited: Note = LocalCache.getOrCreateNote(EDITED_ID) + val editVersion: Note = LocalCache.getOrCreateNote(EDIT_VERSION_ID) + val draft: Note = LocalCache.getOrCreateAddressableNote(draftEvent.address()) + val communityPost: Note = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) + val geoPowExpiring: Note = LocalCache.getOrCreateNote(POW_ID) + val kitchenSink: Note = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume( @@ -176,16 +177,6 @@ fun NoteHeaderFirstRowDensityPreview() { null, true, ) - - plain = LocalCache.getOrCreateNote(PLAIN_ID) - repost = LocalCache.getOrCreateNote(REPOST_ID) - rumorPinned = LocalCache.getOrCreateNote(RUMOR_ID) - edited = LocalCache.getOrCreateNote(EDITED_ID) - editVersion = LocalCache.getOrCreateNote(EDIT_VERSION_ID) - draft = LocalCache.getOrCreateAddressableNote(draftEvent.address()) - communityPost = LocalCache.getOrCreateNote(COMMUNITY_POST_ID) - geoPowExpiring = LocalCache.getOrCreateNote(POW_ID) - kitchenSink = LocalCache.getOrCreateNote(KITCHEN_SINK_ID) } } From 41ef1d89b3856736a50999b0ac24ae87c4e5a687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 12:59:30 +0000 Subject: [PATCH 151/206] feat(ui): add the OpenTimestamps logo as a custom glyph and use it in the OTS pill Bake the official OpenTimestamps stamp logo (traced from opentimestamps/logo vector.svg; monochrome outline, tinted at render time like every other glyph) into the Material Symbols subset font at U+F8F0. New tools/material-symbols-subset/add_custom_glyphs.py converts the traced SVGs in custom/ into TrueType glyphs and is invoked by subset.sh after pyftsubset, so font regenerations keep them. With the logo identifying the pill, drop the verbose "OTS:" prefix: the pill now reads icon + "2y" (or icon + "Pending", new R.string.pending). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/elements/DisplayOts.kt | 8 +- amethyst/src/main/res/values/strings.xml | 1 + .../font/material_symbols_outlined.ttf | Bin 464004 -> 466160 bytes .../commons/icons/symbols/MaterialSymbols.kt | 6 + tools/material-symbols-subset/README.md | 12 ++ .../add_custom_glyphs.py | 104 ++++++++++++++ .../custom/opentimestamps.svg | 130 ++++++++++++++++++ tools/material-symbols-subset/subset.sh | 4 + 8 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 tools/material-symbols-subset/add_custom_glyphs.py create mode 100644 tools/material-symbols-subset/custom/opentimestamps.svg diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 319293d69c..ea62ebdb9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -61,8 +61,8 @@ fun DisplayOts( } HeaderPill( - symbol = MaterialSymbols.CheckCircle, - text = stringRes(R.string.existed_since, timeStr), + symbol = MaterialSymbols.OpenTimestamps, + text = timeStr, contentDescription = stringRes(R.string.ots_info_title), onClick = { accountViewModel.toastManager.toast( @@ -75,8 +75,8 @@ fun DisplayOts( }, whenPending = { HeaderPill( - symbol = MaterialSymbols.Schedule, - text = stringRes(id = R.string.timestamp_pending_short), + symbol = MaterialSymbols.OpenTimestamps, + text = stringRes(id = R.string.pending), contentDescription = stringRes(R.string.ots_info_title), ) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a3ef6e6b7d..71c5cba0c2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,6 +45,7 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending + Pending Request Deletion Block / Report diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 01bd58472eab53e9922f2ac64092cd463a60370c..fde397e9efc2d552e8205b3b0ec61759f5f3a6df 100644 GIT binary patch delta 2930 zcmY*bYlu|W9Y62KJ@?#uXXeh#x-Z>_JIQ9N>+C!rQ4*V)q@)j%Xt9N2m$+(ZR+qS~ zrmyM4#)U{LEG7LILMW6#5VkZa{h&zFQXAUlL+O`N8d0&0QlSmxL#X|oJFcep%(?gc zpTGa>EPL|j!#965e5TgB`Vk@Y^kZMx`Q(c)t?VM?+6p0j@bgb?ZSH(x|3i@dlo0Zj zr}pgJbL!^uON6}tD(JW7zdrvQiT`nykn4v*?|yo5ere&`7f%u*t`VXx9)$eGe=oic z`J0emKQw=A3Ezy6tN+2*9D4TogBSjM_K&1T5;#0}{tyg)D8IFlkQ=WOwe>TH7v>Li ze|zQv0vLut=P(%hk{$!S2lV9O#pM(GUfX*U^oNA7o6jD7dj7quwM&G!Uj%w_{=^bJ z&Fhe#fc~Z<^NR~#dh&bk5b}ouNHAVHdTjZ`Urv3uN65Q~ta_4AsId;mgk#d_`EG@W4)Ny7h991oE~#9M>Njbm(zjBYD9JNbAq) z*564(1V2yD5V=}ks_~Y`#Okzgl{GhS7b%$?vC+(8t#0cfWKyG_gPa(AhxI zEya6=c3?g^O8dD4K|;$x6dsxsbOx0={RreVDo}IH zfY(3Pwv7jV0ImiP&H8K+NFgosv}yx5Sm->25Duz68<>3}-&SY@a@%YOFPwrBfa-Kp!a|JojI&-2N4C?_yi2#r~tTY0A?GjF$H<(m#Sii z72LH8s5=t$HE@GPW2#q}gi=?Feg|#Hrdb^<@b2^y@NFd4>>#&V6LXN~H8ckp(-QH9 zP#RNH0TSHMv0A-8*9>W4ZD;N6Y^unuF*_~bJFiv)WW85|yc@EC{rJ?x1i%{st0yk@Ok^toFA6@>~`A*O!H!3kXHFk{_LVN>7-Cj|vR z@Dbb=RX`&GDjG6QKcR%Af(xZ0XO*OsTds|)OP-}lN0Mu+;^{;((QsbSycAnUDOHt; z=^?MeDH*NglNpZEkLCG2iJ+AEo^@%N3m*?xlWfdLVPm(kRP}XhI0e9r)-y)q$VDSd z^U8#ze6W%;DmOPKD;7TF=7Cn0^Z|xieJD2=JI<%e-nz|MWQB-q;x;&471~P8m{7J7 zdnFX>ZDgKN@5keq$9$Aq!CgL>0-Q<1Q|K5rkd1{1gyo~mNQP8Kx!}wJoI&Qe(GR~PS)ETs3>{yRi5UTZ64LHiUvO_WIY^ici(*S`1 z(8|I=iAPMvkq!a7(ty!L0NgSWml$#>X+2Pim6R;YzFgLblX7KZM!BJCpgsFw$5UWX z7SXBuKl#8EwIF&hIk}+p|Rzi!o+>niwTyF^Fr#$u}5o|F9j4>OJIrP0@9T(Md zWGw7u^1#TbWjJIHk5q~v(k-|QVrv=-Z6+tjHX=1v$w#P|E>{M86>*z;$}X;!bKu4C zNMvgpe2jT;@u0&ElADKydz(Eb%GlIYMylafVai6hlq!xZhzPFHZc*_u?N1ZHFoUWb zSDi|=vzR~11ZH5^nK)Z#Xn{L3A}Dj17_U*3;8>Y7@i^k@_6G^E^x}9jj6SB=3l=6q zz*ff*Iw~=c7~Le6EaJ{>@}+>Jol{hDRWn`}bq0t|A|n7tnez4i&A{M`Qe0L{+5{62 zt_77Usv%uOU&d{qKM^I-vjdd~n3A%XmA&fs*HC}&6Oa6!VfHLsmYFtaA+vcsLK+-6 z+C(&+`QqcB-nXU9S*#dcKy2+cvt{&L3Jfv#p~$FVr;!G?+cEIUc+9%PpDC{6AU?p7 z$TB#r7zJKrd2vNFE;vW27*jft>Ik#Kl`uSzhsmPUYxEt(@IYd@RfrO*XkHL9%(uXh z0^J(J)tdgz7S503-;d8-y0*6IB4&&oUmJgCp`0d1mllpJKeM=SYDb!J-=3GN hH!km9rI)Y&V8@kHJFZY{hRv}BwtQp974_)W{{d$pa4G-* delta 750 zcmYjOUr1A76hG(Q{nce<(?myE>>;EYMU+UO1N-w3WNH#c*k#U*a&6>V69!t@Tlezj}a|2g1?ah(iJj& znQ^Qz zE+sbOE${`{K|7I!t^AO;D2ehPLX*k>VWl~p^M<-N9$qmBViG5`TldGxJB(ky+s7fSk(^9 zq|xDFISitwuu`TSW+keNuu^LXb4^hLS;;sEZHR~DRM{?hk~IQ%VJLGGxAM`FX3aC^ zRI%Q8ugZ9Lac$rOXwJHgD@zhKG5+hSu?a#GSD?aH{tIE_#` to patch the existing font). diff --git a/tools/material-symbols-subset/add_custom_glyphs.py b/tools/material-symbols-subset/add_custom_glyphs.py new file mode 100644 index 0000000000..158c98e438 --- /dev/null +++ b/tools/material-symbols-subset/add_custom_glyphs.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Append custom (non-Material) glyphs to the subset Material Symbols font. + +Some icons Amethyst needs are third-party logos that Google's Material +Symbols font will never carry (e.g. the OpenTimestamps stamp). Their traced +SVG outlines live in `custom/` and this script bakes them into the subset +TTF at private-use codepoints, so `MaterialSymbols.kt` can reference them +exactly like any Material glyph. + +subset.sh runs this automatically after pyftsubset; it can also be run +directly against an existing font: + + python3 add_custom_glyphs.py + +The SVGs are potrace output: the raw path coordinates are y-up already (the +file's group transform re-flips them for SVG display), so they map into font +space with a plain uniform scale + translate. +""" + +import os +import re +import sys + +from fontTools.pens.boundsPen import BoundsPen +from fontTools.pens.cu2quPen import Cu2QuPen +from fontTools.pens.transformPen import TransformPen +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.svgLib.path import parse_path +from fontTools.ttLib import TTFont + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# codepoint -> (glyph name, traced SVG). Keep codepoints at the very end of +# the BMP private-use area (U+F8xx tail) to stay clear of the range Material +# Symbols actually allocates. +CUSTOM = { + 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg")), +} + +# Fit content into the same square Material Symbols draw in (its check_circle +# glyph spans 80..880 in a 960 upm font). Scaled to the font's real upm. +BOX_MIN_FRAC = 80 / 960 +BOX_MAX_FRAC = 880 / 960 + + +def svg_path_data(svg_file): + with open(svg_file, "r", encoding="utf-8") as f: + svg = f.read() + return re.findall(r']*\bd="([^"]+)"', svg) + + +def draw_svg(paths, pen): + for d in paths: + parse_path(d, pen) + + +def build_glyph(paths, upm): + # First pass: raw outline bounds. + bounds = BoundsPen(None) + draw_svg(paths, bounds) + x_min, y_min, x_max, y_max = bounds.bounds + + # Uniform scale into the Material content box, centered on both axes. + box_min = BOX_MIN_FRAC * upm + box_max = BOX_MAX_FRAC * upm + box = box_max - box_min + scale = box / max(x_max - x_min, y_max - y_min) + tx = box_min + (box - (x_max - x_min) * scale) / 2 - x_min * scale + ty = box_min + (box - (y_max - y_min) * scale) / 2 - y_min * scale + + tt_pen = TTGlyphPen(None) + quad_pen = Cu2QuPen(tt_pen, max_err=1.0) + draw_svg(paths, TransformPen(quad_pen, (scale, 0, 0, scale, tx, ty))) + return tt_pen.glyph() + + +def main(font_path): + font = TTFont(font_path) + upm = font["head"].unitsPerEm + order = font.getGlyphOrder() + + for codepoint, (name, svg_file) in sorted(CUSTOM.items()): + glyph = build_glyph(svg_path_data(svg_file), upm) + + if name not in order: + order.append(name) + font.setGlyphOrder(order) + font["glyf"][name] = glyph + glyph.recalcBounds(font["glyf"]) + font["hmtx"][name] = (upm, glyph.xMin) + for table in font["cmap"].tables: + if table.isUnicode(): + table.cmap[codepoint] = name + print(f"Added {name} at U+{codepoint:04X}") + + font["maxp"].numGlyphs = len(order) + font.save(font_path) + print(f"Wrote {font_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/tools/material-symbols-subset/custom/opentimestamps.svg b/tools/material-symbols-subset/custom/opentimestamps.svg new file mode 100644 index 0000000000..054387593f --- /dev/null +++ b/tools/material-symbols-subset/custom/opentimestamps.svg @@ -0,0 +1,130 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + diff --git a/tools/material-symbols-subset/subset.sh b/tools/material-symbols-subset/subset.sh index 0689a78aec..640964d342 100755 --- a/tools/material-symbols-subset/subset.sh +++ b/tools/material-symbols-subset/subset.sh @@ -59,4 +59,8 @@ pyftsubset "$SOURCE_TTF" \ --glyph-names \ --symbol-cmap +# Bake in the custom (non-Material) glyphs from custom/ — third-party logos +# like OpenTimestamps that the upstream font will never carry. +python3 "$REPO_ROOT/tools/material-symbols-subset/add_custom_glyphs.py" "$TARGET" + echo "Wrote $TARGET ($(du -h "$TARGET" | cut -f1))" From 170bc7c121e0cdd1a4b36952096780c02a46910f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:30:38 +0000 Subject: [PATCH 152/206] fix(ui): restore full-size quiet marks, tighten the dotted timestamp, drop Boosted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiet marks go back to the row's regular text size in bold with 16dp icons; the hashtag/community soft links lose their 12sp override too (the smaller tier read as too small). A new TimeAgoStyle.DottedTight renders "• 5m" without the leading space for rows whose spacedBy already provides the gap, removing the double space before the timestamp. The OTS pending pill shrinks to the stamp icon plus an ellipsis (the words move to the content description). The Boosted mark is removed entirely — from the Android header, the commons component, and the desktop feed — since the repost context is already visible. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 8 +--- .../amethyst/ui/note/elements/BoostedMark.kt | 31 -------------- .../ui/note/elements/DisplayCommunity.kt | 3 -- .../ui/note/elements/DisplayHashtags.kt | 3 -- .../amethyst/ui/note/elements/DisplayOts.kt | 6 ++- .../note/elements/NoteHeaderMarkersPreview.kt | 8 ---- .../amethyst/ui/note/elements/TimeAgo.kt | 22 ++++++++-- .../loggedIn/threadview/ThreadFeedView.kt | 3 +- amethyst/src/main/res/values/strings.xml | 1 - .../commons/ui/elements/BoostedMark.kt | 40 ------------------- .../amethyst/commons/ui/note/QuietMark.kt | 15 +++---- .../amethyst/desktop/ui/FeedScreen.kt | 2 - 12 files changed, 31 insertions(+), 111 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt delete mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.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 041d3b37ff..f28d0fc270 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 @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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 -import com.vitorpamplona.amethyst.ui.note.elements.BoostedMark import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingCommunityInPost import com.vitorpamplona.amethyst.ui.note.elements.DisplayFollowingHashtagsInPost @@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.StaleRelayHint import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList @@ -1834,10 +1834,6 @@ fun FirstUserInfoRow( NoteUsernameDisplay(baseNote, Modifier.weight(1f), textColor = textColor, accountViewModel = accountViewModel) } - if (isRepost) { - BoostedMark() - } - CheckAndDisplayEditStatus(editState) if (isDraft) { @@ -1899,7 +1895,7 @@ fun FirstUserInfoRow( // The dotted TimeAgo carries its own " • " separator and the options // button has slack around its icon, so this pair stays unspaced. Row(verticalAlignment = CenterVertically) { - TimeAgo(baseNote) + TimeAgo(baseNote, style = TimeAgoStyle.DottedTight) if (moreOptions == null) { MoreOptionsButton(baseNote, editState, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt deleted file mode 100644 index c48531aaca..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/BoostedMark.kt +++ /dev/null @@ -1,31 +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.amethyst.ui.note.elements - -import androidx.compose.runtime.Composable -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.ui.note.QuietMark -import com.vitorpamplona.amethyst.ui.stringRes - -@Composable -fun BoostedMark() { - QuietMark(text = stringRes(id = R.string.boosted)) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt index 64b27c68a5..a2a68c1fca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayCommunity.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -35,7 +34,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo 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.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress @@ -66,7 +64,6 @@ private fun DisplayCommunity( ClickableTextColor( label, - style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt index cc48d6d08e..cd56c146fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayHashtags.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -39,7 +38,6 @@ import com.vitorpamplona.amethyst.model.Note 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.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.nip01Core.tags.hashtags.firstIsTaggedHashes import com.vitorpamplona.quartz.nip22Comments.CommentEvent @@ -85,7 +83,6 @@ private fun DisplayTagList( ) { ClickableTextColor( "#$firstTag", - style = LocalTextStyle.current.copy(fontSize = Font12SP), linkColor = MaterialTheme.colorScheme.lessImportantLink, overflow = TextOverflow.Ellipsis, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index ea62ebdb9d..43ffee19e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -74,10 +74,12 @@ fun DisplayOts( ) }, whenPending = { + // The stamp icon plus an ellipsis: attestation requested, not yet + // anchored. The content description carries the words. HeaderPill( symbol = MaterialSymbols.OpenTimestamps, - text = stringRes(id = R.string.pending), - contentDescription = stringRes(R.string.ots_info_title), + text = "…", + contentDescription = stringRes(R.string.timestamp_pending_short), ) }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index 5ea22ae855..efac0ed3ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction.repost import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.LocalCache @@ -44,7 +43,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers @@ -59,7 +57,6 @@ private val AUTHOR_METADATA_ID = "e1".repeat(32) private val LONG_NAME_METADATA_ID = "e2".repeat(32) private val PLAIN_ID = "1".repeat(64) -private val REPOST_ID = "2".repeat(64) private val RUMOR_ID = "3".repeat(64) private val EDITED_ID = "4".repeat(64) private val EDIT_VERSION_ID = "5".repeat(64) @@ -108,7 +105,6 @@ fun NoteHeaderFirstRowDensityPreview() { val draftEvent = DraftWrapEvent(DRAFT_ID, AUTHOR, now - 300, arrayOf(arrayOf("d", "preview-draft")), "", "x") val plain: Note = LocalCache.getOrCreateNote(PLAIN_ID) - val repost: Note = LocalCache.getOrCreateNote(REPOST_ID) val rumorPinned: Note = LocalCache.getOrCreateNote(RUMOR_ID) val edited: Note = LocalCache.getOrCreateNote(EDITED_ID) val editVersion: Note = LocalCache.getOrCreateNote(EDIT_VERSION_ID) @@ -132,7 +128,6 @@ fun NoteHeaderFirstRowDensityPreview() { LocalCache.justConsume(plainEvent, null, true) LocalCache.justConsume(TextNoteEvent(EDIT_VERSION_ID, AUTHOR, now - 60, emptyArray(), "GM! (fixed typo)", "x"), null, true) - LocalCache.justConsume(RepostEvent(REPOST_ID, AUTHOR, now - 300, arrayOf(arrayOf("e", PLAIN_ID)), plainEvent.toJson(), "x"), null, true) // An empty sig is what marks a note as a private rumor. LocalCache.justConsume(TextNoteEvent(RUMOR_ID, AUTHOR, now - 300, emptyArray(), "just between us", ""), null, true) LocalCache.justConsume(TextNoteEvent(EDITED_ID, AUTHOR, now - 300, emptyArray(), "GM!", "x"), null, true) @@ -197,9 +192,6 @@ fun NoteHeaderFirstRowDensityPreview() { // Bare minimum: username + time + options HeaderRowSample(plain, accountViewModel) - // One quiet mark: repost - HeaderRowSample(repost, accountViewModel) - // Quiet icon marks: private rumor + pinned HeaderRowSample(rumorPinned, accountViewModel, isPinned = true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index c23fd2d41c..a552e99cfa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -53,6 +53,12 @@ enum class TimeAgoStyle { /** "• 5m", uses [timeAgo]. Used by note timestamps and chatroom row last-message time. */ Dotted, + /** + * "• 5m" without the leading space — for rows whose `Arrangement.spacedBy` + * already provides the gap before the timestamp (the note-header first row). + */ + DottedTight, + /** "5m" (no dot, no leading space), uses [timeAgoShort]. Used by chat bubbles and channel headers. */ Short, } @@ -96,6 +102,7 @@ fun ToggleableTimeAgoText( if (showAbsolute) { when (style) { TimeAgoStyle.Dotted -> timeAbsolute(timestamp, context) + TimeAgoStyle.DottedTight -> timeAbsolute(timestamp, context).trimStart() TimeAgoStyle.Short -> timeAbsoluteNoDot(timestamp, context) } } else { @@ -104,6 +111,7 @@ fun ToggleableTimeAgoText( nowState.value when (style) { TimeAgoStyle.Dotted -> timeAgo(timestamp, context) + TimeAgoStyle.DottedTight -> timeAgo(timestamp, context).trimStart() TimeAgoStyle.Short -> timeAgoShort(timestamp, nowStr) } } @@ -125,16 +133,22 @@ fun ToggleableTimeAgoText( } @Composable -fun TimeAgo(note: Note) { +fun TimeAgo( + note: Note, + style: TimeAgoStyle = TimeAgoStyle.Dotted, +) { val time = note.createdAt() ?: return - TimeAgo(time) + TimeAgo(time, style) } @Composable -fun TimeAgo(time: Long) { +fun TimeAgo( + time: Long, + style: TimeAgoStyle = TimeAgoStyle.Dotted, +) { ToggleableTimeAgoText( timestamp = time, - style = TimeAgoStyle.Dotted, + style = style, color = MaterialTheme.colorScheme.placeholderText, ) } 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 f2762f30bf..d3a2b8a1dd 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 @@ -127,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.Reward import com.vitorpamplona.amethyst.ui.note.elements.ShowForkInformation import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgoStyle import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.types.AudioHeader @@ -744,7 +745,7 @@ private fun FullBleedNoteCompose( // The dotted TimeAgo carries its own " • " separator and the // options button has slack around its icon: keep the pair unspaced. Row(verticalAlignment = Alignment.CenterVertically) { - TimeAgo(note = baseNote) + TimeAgo(note = baseNote, style = TimeAgoStyle.DottedTight) MoreOptionsButton(baseNote, editState, accountViewModel, nav) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 71c5cba0c2..a3ef6e6b7d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,7 +45,6 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending - Pending Request Deletion Block / Report diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt deleted file mode 100644 index 8308fdfcfc..0000000000 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/elements/BoostedMark.kt +++ /dev/null @@ -1,40 +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.amethyst.commons.ui.elements - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp - -@Composable -fun BoostedMark() { - Text( - "Boosted", - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.padding(start = 5.dp), - ) -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt index 8f3cea79a7..2e0374c0d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/QuietMark.kt @@ -31,18 +31,15 @@ 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.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText -private val QuietMarkFontSize = 12.sp - /** - * Quiet passive-state marker for note headers: Boosted, Draft, Edited, pinned, + * Quiet passive-state marker for note headers: Draft, Edited, pinned, * private rumor, ... One spec for all of them so the header reads as a single - * system: 12sp medium gray text with an optional 12dp icon. Contrast with - * [HeaderPill], which is the loud tier for tappable/verifiable metadata. + * system: bold gray text at the row's text size with an optional 16dp icon. + * Contrast with [HeaderPill], the chip tier for tappable/verifiable metadata. * * Spacing between markers is owned by the parent row (`Arrangement.spacedBy`), * not baked in here. @@ -65,16 +62,14 @@ fun QuietMark( symbol = symbol, contentDescription = contentDescription, tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.size(12.dp), + modifier = Modifier.size(16.dp), ) } if (text != null) { Text( text = text, color = MaterialTheme.colorScheme.placeholderText, - fontSize = QuietMarkFontSize, - lineHeight = QuietMarkFontSize, - fontWeight = FontWeight.Medium, + fontWeight = FontWeight.Bold, maxLines = 1, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ec07c0e06b..1eace52ec5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip import com.vitorpamplona.amethyst.commons.ui.feeds.StickToTopOnPrepend @@ -304,7 +303,6 @@ private fun FeedNoteCardBody( ) }, ) - BoostedMark() } // Original note content with actions inside card From 811d9cf80243b596ef8bdbe2f60295b697d39204 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:46:12 +0000 Subject: [PATCH 153/206] feat(ui): icon-only fork mark, pencil edit mark, tighter expiration and location pills - The fork marker drops "Forked from " for a bare fork-right icon (new MaterialSymbols.ForkRight, U+EBAC); tapping still opens the original version. Font regenerated via subset.sh, which also re-baked the custom OpenTimestamps glyph, proving the custom-glyph pipeline survives regeneration. - The edited mark becomes a pencil: bare pencil for the latest edit, pencil + "#2"/"original" only while cycling versions on tap. - The expiration pill clamps beyond one year to "1y+" instead of switching to a full date. - The location pill caps at 110dp and ellipsizes, so unbounded city names cannot squeeze the author's name out of the row. - Remove the now-unused existed_since string from all 35 locale files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 11 +++++- .../ui/note/elements/DisplayEditStatus.kt | 16 ++++++-- .../ui/note/elements/DisplayLocation.kt | 6 +++ .../amethyst/ui/note/elements/ForkInfo.kt | 36 ++++++------------ amethyst/src/main/res/values-cs/strings.xml | 1 - .../src/main/res/values-de-rDE/strings.xml | 1 - amethyst/src/main/res/values-de/strings.xml | 1 - amethyst/src/main/res/values-eo/strings.xml | 1 - .../src/main/res/values-es-rES/strings.xml | 1 - .../src/main/res/values-es-rMX/strings.xml | 1 - .../src/main/res/values-es-rUS/strings.xml | 1 - amethyst/src/main/res/values-es/strings.xml | 2 - .../src/main/res/values-fa-rIR/strings.xml | 1 - amethyst/src/main/res/values-fa/strings.xml | 1 - .../src/main/res/values-fr-rFR/strings.xml | 1 - amethyst/src/main/res/values-fr/strings.xml | 1 - .../src/main/res/values-hi-rIN/strings.xml | 1 - .../src/main/res/values-hu-rHU/strings.xml | 1 - amethyst/src/main/res/values-in/strings.xml | 2 - amethyst/src/main/res/values-ja/strings.xml | 2 - .../src/main/res/values-nl-rBE/strings.xml | 2 - .../src/main/res/values-nl-rNL/strings.xml | 1 - amethyst/src/main/res/values-nl/strings.xml | 1 - .../src/main/res/values-pl-rPL/strings.xml | 1 - .../src/main/res/values-pt-rBR/strings.xml | 1 - amethyst/src/main/res/values-ru/strings.xml | 2 - .../src/main/res/values-sl-rSI/strings.xml | 1 - .../src/main/res/values-sv-rSE/strings.xml | 1 - amethyst/src/main/res/values-ta/strings.xml | 1 - .../src/main/res/values-th-rTH/strings.xml | 1 - amethyst/src/main/res/values-th/strings.xml | 1 - amethyst/src/main/res/values-tr/strings.xml | 2 - amethyst/src/main/res/values-uk/strings.xml | 2 - .../src/main/res/values-zh-rCN/strings.xml | 1 - .../src/main/res/values-zh-rHK/strings.xml | 1 - .../src/main/res/values-zh-rSG/strings.xml | 1 - .../src/main/res/values-zh-rTW/strings.xml | 1 - amethyst/src/main/res/values-zh/strings.xml | 2 - amethyst/src/main/res/values/strings.xml | 2 +- .../font/material_symbols_outlined.ttf | Bin 466160 -> 469812 bytes .../commons/icons/symbols/MaterialSymbols.kt | 1 + 41 files changed, 42 insertions(+), 72 deletions(-) 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 f28d0fc270..56c40f13d6 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 @@ -351,6 +351,7 @@ import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent import com.vitorpamplona.quartz.nipXXPodcasting20.metadata.Podcasting20PodcastMetadata import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -1762,9 +1763,17 @@ fun SecondUserInfoRow( @Composable fun DisplayExpiration(expirationDate: Long) { val context = LocalContext.current + // Beyond a year timeAheadNoDot switches to a full date, which is too wide + // for the pill: clamp it. + val text = + if (expirationDate - TimeUtils.now() > TimeUtils.ONE_YEAR) { + stringRes(R.string.one_year_plus) + } else { + timeAheadNoDot(expirationDate, context) + } HeaderPill( symbol = MaterialSymbols.Timer, - text = timeAheadNoDot(expirationDate, context), + text = text, contentDescription = stringRes(R.string.expiration_date_label), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt index 0ffae65a1d..355d205e2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayEditStatus.kt @@ -22,23 +22,31 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.runtime.Composable import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.stringRes +/** + * Pencil marking an edited note; tapping cycles through the versions. The + * bare pencil is the latest edit — a suffix appears only while browsing + * older versions. + */ @Composable fun DisplayEditStatus(editState: EditState) { val label = - if (editState.showingVersion.value == editState.originalVersionId()) { + if (editState.showingVersion.value == editState.lastVersionId()) { + null + } else if (editState.showingVersion.value == editState.originalVersionId()) { stringRes(id = R.string.original) - } else if (editState.showingVersion.value == editState.lastVersionId()) { - stringRes(id = R.string.edited) } else { - stringRes(id = R.string.edited_number, editState.versionId()) + "#${editState.versionId()}" } QuietMark( + symbol = MaterialSymbols.Edit, text = label, + contentDescription = stringRes(id = R.string.edited), onClick = { editState.nextModification() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt index a32e65d1b5..e533cc1cb3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayLocation.kt @@ -20,7 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.elements +import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -31,6 +34,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** * Compact pill showing the geohash a note is scoped to, resolved to a city * name. Tapping it opens the geohash feed. Sits inline in note headers. + * City names are unbounded user data, so the pill is capped and ellipsizes + * to protect the author's name from being squeezed out of the row. */ @Composable fun DisplayLocation( @@ -42,6 +47,7 @@ fun DisplayLocation( HeaderPill( symbol = MaterialSymbols.LocationOn, text = cityName, + modifier = Modifier.widthIn(max = 110.dp), onClick = { nav.nav(Route.Geohash(geohashStr)) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt index 7b6486bfd9..d9a32e94a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ForkInfo.kt @@ -20,26 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.MaterialTheme 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 com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.note.QuietMark import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Font14SP -import com.vitorpamplona.amethyst.ui.theme.lessImportantLink import com.vitorpamplona.quartz.experimental.forks.IForkableEvent @Composable @@ -54,20 +49,21 @@ fun ShowForkInformation( if (forkedAddress != null) { LoadAddressableNote(forkedAddress, accountViewModel) { addressableNote -> if (addressableNote != null) { - ForkInformationRowLightColor(addressableNote, modifier, accountViewModel, nav) + ForkMark(addressableNote, modifier, accountViewModel, nav) } } } else if (forkedEvent != null) { LoadNote(forkedEvent, accountViewModel) { event -> if (event != null) { - ForkInformationRowLightColor(event, modifier, accountViewModel, nav) + ForkMark(event, modifier, accountViewModel, nav) } } } } +/** Fork-right icon marking a forked note; tapping opens the original version. */ @Composable -fun ForkInformationRowLightColor( +fun ForkMark( originalVersion: Note, modifier: Modifier = Modifier, accountViewModel: AccountViewModel, @@ -75,22 +71,14 @@ fun ForkInformationRowLightColor( ) { val noteState by observeNote(originalVersion, accountViewModel) val note = noteState.note - val author = note.author ?: return val route = remember(note) { routeFor(note, accountViewModel.account) } if (route != null) { - Row(modifier, verticalAlignment = Alignment.CenterVertically) { - val userState by observeUserInfo(author, accountViewModel) - - CreateClickableTextWithEmoji( - clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()), - maxLines = 1, - route = route, - overrideColor = MaterialTheme.colorScheme.lessImportantLink, - fontSize = Font14SP, - nav = nav, - tags = userState?.tags, - ) - } + QuietMark( + symbol = MaterialSymbols.ForkRight, + contentDescription = stringRes(id = R.string.forked_from), + modifier = modifier, + onClick = { nav.nav(route) }, + ) } } diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index f9a50c85e7..b27e809d56 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2797,7 +2797,6 @@ Zdroj: Servery: Otevřít - OTS: %1$s Důkaz časového razítka Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. Upravit článek diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 090be920d0..1f106783f7 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2683,7 +2683,6 @@ Quelle: Server: Öffnen - OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. Artikel bearbeiten diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index 12b2abea2c..33afa5019d 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -680,7 +680,6 @@ anz der Bedingungen ist erforderlich Geforkt von Internet: Klonen: - OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. Beitrag bearbeiten diff --git a/amethyst/src/main/res/values-eo/strings.xml b/amethyst/src/main/res/values-eo/strings.xml index 0c1c4e18cf..2d610d685d 100644 --- a/amethyst/src/main/res/values-eo/strings.xml +++ b/amethyst/src/main/res/values-eo/strings.xml @@ -2125,7 +2125,6 @@ Fonto: Serviloj: Malfermi -OTS: %1$s Tempomarko-Pruvo Estas pruvo ke ĉi tiu afiŝo estis subskribita iam antaŭ %1$s. La pruvo estis stampita en la Bitcoin-blokĉeno en tiu dato kaj horo. Redakti Artikolon diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 4243cf8549..8a758dad23 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2297,7 +2297,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 6df3bf7d0f..6c76d29dcc 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index 05a196bd7d..feb5c202e2 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es/strings.xml b/amethyst/src/main/res/values-es/strings.xml index 8ca8201d19..8af516d72d 100644 --- a/amethyst/src/main/res/values-es/strings.xml +++ b/amethyst/src/main/res/values-es/strings.xml @@ -2618,8 +2618,6 @@ Clon: -OTS: %1$s - Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 10dad02bfb..429bc76504 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2253,7 +2253,6 @@ منبع: سرورها: باز کردن - OTS: %1$s اثبات مهر زمان مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. ویرایش مقاله diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index d2c9bce73b..1cf69b0c1c 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -665,7 +665,6 @@ انشعاب شده از وب: نسخه برداری: - OTS: %1$s اثبات مهر زمان مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. ویرایش نوشته diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5caec58f87..3b445362af 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2511,7 +2511,6 @@ Source : Serveurs : Ouvrir - OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. Modifier l\'article diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index abbf896ec1..6bb2c6ea7c 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -670,7 +670,6 @@ Forké depuis Web : Clone : - OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. Modifier le Message diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 90d73c13a3..1124b60683 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2730,7 +2730,6 @@ स्रोत : सेवासंगणक : खोलें - ओ॰टी॰एस : %1$s समयांकन प्रमाण प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। लेख सम्पादित करें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c667c9f116..517a94c213 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2731,7 +2731,6 @@ Forrás: Kiszolgálók: Megnyitás - OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. Cikk szerkesztése diff --git a/amethyst/src/main/res/values-in/strings.xml b/amethyst/src/main/res/values-in/strings.xml index 51cc6610c8..bcb14b65b9 100644 --- a/amethyst/src/main/res/values-in/strings.xml +++ b/amethyst/src/main/res/values-in/strings.xml @@ -2726,8 +2726,6 @@ Buka - OTS: %1$s - Bukti Stempel Waktu Ada bukti bahwa postingan ini ditandatangani sebelum %1$s. Bukti ini dicap dalam blockchain Bitcoin pada tanggal dan waktu tersebut. diff --git a/amethyst/src/main/res/values-ja/strings.xml b/amethyst/src/main/res/values-ja/strings.xml index 8ddae5afed..59ec50d5b1 100644 --- a/amethyst/src/main/res/values-ja/strings.xml +++ b/amethyst/src/main/res/values-ja/strings.xml @@ -2818,8 +2818,6 @@ 開く - OTS: %1$s - タイムスタンプ証明 この投稿が %1$s より前に署名されたことの証明があります。この証明はその日時に Bitcoin ブロックチェーンに刻印されています。 diff --git a/amethyst/src/main/res/values-nl-rBE/strings.xml b/amethyst/src/main/res/values-nl-rBE/strings.xml index 73a4c7371e..6bb2bf83b3 100644 --- a/amethyst/src/main/res/values-nl-rBE/strings.xml +++ b/amethyst/src/main/res/values-nl-rBE/strings.xml @@ -3798,8 +3798,6 @@ Servers: -OTS: %1$s - Bewijs van tijdstempel Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain. diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 48933a0fb3..4bed90f621 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2566,7 +2566,6 @@ Bron: Servers: Openen - OTS: %1$s Bewijs van tijdstempel Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain. Artikel bewerken diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index 6ab57fbb53..e43d1b58d8 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -669,7 +669,6 @@ Geforked van Web: Kopiëren: - OTS: %1$s Bewijs van tijdstempel Er is bewijs dat dit bericht enige tijd voor %1$sis getekend. Het bewijs is op dat tijdstip en op dat moment getypeerd in de Bitcoin-blockchain. Bericht wijzigen diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index cf336b07d8..23c0a216cf 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2798,7 +2798,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Źródło: Serwery: Otwórz - OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. Redaguj artykuł diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 2a7f29143b..4b0e0381de 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2680,7 +2680,6 @@ Fonte: Servidores: Abrir - OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. Editar artigo diff --git a/amethyst/src/main/res/values-ru/strings.xml b/amethyst/src/main/res/values-ru/strings.xml index 6d6d33c1ad..cc57214b33 100644 --- a/amethyst/src/main/res/values-ru/strings.xml +++ b/amethyst/src/main/res/values-ru/strings.xml @@ -2596,8 +2596,6 @@ Открыть - OTS: %1$s - Доказательство временно́й метки Есть доказательство того, что эта публикация была подписана до %1$s. Доказательство зафиксировано в блокчейне Bitcoin в указанную дату и время. diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7470dcfef9..53783ca50f 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2813,7 +2813,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Vir: Strežniki: Odpri - OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. Uredi članek diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 83a1991f8d..734be267e9 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2688,7 +2688,6 @@ Källa: Servrar: Öppna - OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. Redigera artikel diff --git a/amethyst/src/main/res/values-ta/strings.xml b/amethyst/src/main/res/values-ta/strings.xml index e7f9170abc..bcc0f3b33c 100644 --- a/amethyst/src/main/res/values-ta/strings.xml +++ b/amethyst/src/main/res/values-ta/strings.xml @@ -2125,7 +2125,6 @@ மூலம்: சேவையகங்கள்: திறக்கவும் -OTS: %1$s நேர முத்திரை சான்று இந்த இடுகை %1$s-க்கு முன்பு ஒரு நேரத்தில் கையொப்பமிடப்பட்டது என்பதற்கான சான்று உள்ளது. சான்று அந்த தேதியிலும் நேரத்திலும் Bitcoin blockchain-ல் முத்திரையிடப்பட்டது. கட்டுரையைத் திருத்து diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index 7be1e93544..2779a9625d 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2171,7 +2171,6 @@ แหล่งที่มา: เซิร์ฟเวอร์: เปิด - OTS: %1$s Timestamp ถูกยืนยัน มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว แก้ไขบทความ diff --git a/amethyst/src/main/res/values-th/strings.xml b/amethyst/src/main/res/values-th/strings.xml index d6a900f1ac..b2ffd57fbb 100644 --- a/amethyst/src/main/res/values-th/strings.xml +++ b/amethyst/src/main/res/values-th/strings.xml @@ -574,7 +574,6 @@ คัดลอกมาจาก เว็บไซน์ สำเนา: - OTS: %1$s Timestamp ถูกยืนยัน มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว แก้ไขโพสต์ diff --git a/amethyst/src/main/res/values-tr/strings.xml b/amethyst/src/main/res/values-tr/strings.xml index 561c98aa9c..d29471ab49 100644 --- a/amethyst/src/main/res/values-tr/strings.xml +++ b/amethyst/src/main/res/values-tr/strings.xml @@ -2996,8 +2996,6 @@ Aç - OTS: %1$s - Zaman Damgası Kanıtı Bu gönderinin %1$s tarihinden önce imzalandığına dair kanıt var. Kanıt, o tarih ve saatte Bitcoin blok zincirine işlendi. diff --git a/amethyst/src/main/res/values-uk/strings.xml b/amethyst/src/main/res/values-uk/strings.xml index d587710e89..4d980bb3ba 100644 --- a/amethyst/src/main/res/values-uk/strings.xml +++ b/amethyst/src/main/res/values-uk/strings.xml @@ -2786,8 +2786,6 @@ Відкрити - OTS: %1$s - Доказ часової позначки Є доказ того, що ця публікація була підписана до %1$s. Доказ засвідчено в блокчейні Bitcoin на ту дату та час. diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3ff2b4c951..ff2d4ec596 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2695,7 +2695,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index fea511090e..2afb33976f 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 4d2541c844..6e804d6206 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 8feb85b6b3..accf6196d6 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2209,7 +2209,6 @@ 來源: 伺服器: 開啟 - OTS:%1$s 時間戳證明 有在 %1$s 之前的某個時間簽署了此帖子的證明。此證明是在那個日期和時間在比特幣區塊鏈中記錄的時間戳。 編輯文章 diff --git a/amethyst/src/main/res/values-zh/strings.xml b/amethyst/src/main/res/values-zh/strings.xml index 4dd6d7bff0..b9f3309a64 100644 --- a/amethyst/src/main/res/values-zh/strings.xml +++ b/amethyst/src/main/res/values-zh/strings.xml @@ -4214,8 +4214,6 @@ 打开 -OTS:%1$s - OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a3ef6e6b7d..9005e9f7e7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -45,6 +45,7 @@ Timestamp it Timestamp: Pending Confirmations OTS: Pending + 1y+ Request Deletion Block / Report @@ -3017,7 +3018,6 @@ Source: Servers: Open -OTS: %1$s Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index fde397e9efc2d552e8205b3b0ec61759f5f3a6df..d75ba235aab931c0844fa28d9529614b88afa8b8 100644 GIT binary patch delta 8827 zcmZ`;30&0G_CNRD-|siWFbo3@Gsvt63Zj4l0s<1?hGs6frsl3$Zlv}^>p381re!I` zDD_PJoB6EB#OIXHT-sbxb15_OsTmp)vNH_(|J)yn_V@j-pF6+#eb0LDx#!%&SoMYJ z;1!b{2mtuthXC54W5=iE|4{HLK0cr<^V9;sIiezsbi+ELfTf8 znlW}<>bO^{mOB9Mt;P3mX8vvFQc&OB4^WYg@1tignwh!PylyEfuL6)R%|rUe+C_y( z=kn#x&Rk~C#4lPjcov_^XBV!Vw=C>L8Gz;pz=;vhqQI-BlDPntYe7mZoIiKwoVb(k z)}sR^CWGjh=Q1tqRfow;P$^1PAV>Hse50Q?TgyF=@r*gt*Ohts{2>Xnoo zM?tu={6(YueCeJoFNF*stMXQpXGm4vHS$N^F?xqo742q22|7lRjp5YcC+;#n1?ec- z-+(U)*ifVrqY3%AC`sCgkG$^`6Ya`C&p^qR7O1eb*>EduE^f$LYAvzpZN01`tY6#S zvhKHbwQ8(!)^O`KtG9KS<(}nT%SP)G%K~eG1;b@IVfnY^L(Auue_0YOQ!EqAPV+*p z#o}w;VyZPQHoalmXAU;4F~tLz+Hm@r7MKQ_x|>o=Np_PbfYIORkDq?V?p(~sj3l7k zI6q)$KtO;o;9%G0fB^y10=fq%yMEYpf&W?mgsyr3|D*o%4BHJ2T*807VY`2VVGDla zfdRu$hVzCShV_QA{+s>R_>2DehLZ-|K|`xyp#LcUG<~uDvSFrSoner{ep3INVWgoK zfc~n%!$9;^`hEC)u|cW-!l2SG(EqG&(f9Ft$?u-uI6c$%(v|AU{CfGV_v^3Q=;N(D z=ku)3zkMpTv$+AiU_a;1Qt8p^#8Q!@_x$ga^x6(U9b42s9w@MT1 zo#q{;`KTKnmI}{j6$_QG$pPbf?ho=Z1}>Yd%{!qA&D&1|N+##+B)SBWPKH0=dH5vB z$c^*SL8AB@=_7wekode49>ErZo7XXjmf*2_@EsYo#1}0XNE1d`q)3^qVJY=r{&5q^LRa0&WB9881v;CG^gvrr7@!3CG$BAkX( za0XJKEA$36R6#Y|fiN;14njKYg+1^Gl#rQZHbg@Wd73PMzK}$ogQa8?>;n_rAklD> zjDar&*abWC4tSonUk%U)%no=bpx7uF6OEIN+l^;Tded-IuBp&eYqpu^n+wdhEIP|* z%QDMB%gVLz1HieUUj|2-VwbA_5OSBeZBt^&cc(zr-ZK!-yMEFyd=UFksh%)qByckSea`ls)c2jfC;K)r$2OmR$2KaYN){WAK!+|T|&zl*Vo*#5DzW8aIt7^jO{5cjXR|HOBXpBBF^erx=` z__OhC{XP5p_Ydzsw0~CrfA;^ie|3T;VOqkQ2|p$36B83hC$=O_P1=%lcz|j^p8?Yb zd^+IDfTm<~a#ZrW$+w;u_{8fez9|_gIVqo~+)VA8IzRPvsw-`5T29*0w1$Cw2ad51 z+&S>aL9+&ZJg9DP;NVGv?Sl&j|1^XR89(IRA@xJ!ht3&#XlP@4NP0^8g!I|z_Vg|3 zpQIm8|1-UPSirE5VFQN!V_5m{>BCPvsdzHx$@x$2d-Cdt6(deQHTJ0!BYTcqIP%J< zo};oy?H%1^^pm6aj=nM`am)*2sxwkEE{^Rv_RDcS#=SSL--YqI@oD3aOwdkvdcwL1 zzfLqw+&b~kNy(G4C)Gba>ggks$>ik8D<`*3nK)(Vl-j8YQ(u~~2cT%TradZ1g3&OV zWwK1Hi=JSFUU+7QVh%*dMH`}7kOCihnX$IKUsKJR^(nS3w&e9G`WPXmtvD~GUsu)3 zQ>T*oSM&4RO7cu;fi~g)XbbtPCX#pL31ctvS3QA|k%8nU|7^_;j2un-kVll-QglT- zQzyktse&wKW9jH2boWtFMJKC!XUQF?>?J+4QOpl*`FkIYYr-?1Y;C1_G_Po2jT z{QDE0de7J?zF9PmzxGdQYqlCG?O>y9K+29H*Ng8?VR*mh9WE;^dijz-C7MN7(p5#- zKh_Dx+_EO5otdRU&1js0bfHov>+=b0+EOZQ48Yj;#ao)h!_3 zeFfxnD3EhWKz_&va``-vD`7y2X92l(6v)lVKyD=fxsw8<)E`KBZy*(a15$kjNNoX- zhAbeBF+kc*0i`OiQ-uK3;{Z^NKTz#Fpt^%V_2obV>VTRPfCgp(4W0!wbTrVg#XuvI zfcC8dnh+24iOE0*odr5{8qkr;fo9AAIu+k%9tS!f`4`3jUA7Qt7V^Cu0CaUI(411B zxh|laP*2`EJJ5nnK(}84`adZ^_xJ++C>dxWDkwtC83FY8HK5<_0eT7zp0)t}z6~g@ zo?c1=`Xf5>%LSm;3b5IzYn~d|{48Mh z=i7iS8V)RT1F)qPz+OxNmbDbvOGsS(F|ajffUU#1J`>pMXzL_?`weVS`O?3)cK(f*xn>ypRNaX0Og$M@DUrZW9Pxnj-!GL z0Vf2ZYp85C&^O7=^qUeL$Fa0E8(52-9)SRNFy#c0UNu7l82M zSrAsu0Kwi3gq%7M*2aUdZXXD^a^VdX2ycD^LjGD13eJGA!wtfog&_Ph7KHtX9YjN) zuL0qUd=UQqJP1c55RQg{a7+)v34DHq#=o5m!gu&QxdEpOgwyEMdHY@vE-we6_z(y; zTRAf8VF@j?lRSLcCv4YAv(`0gGME76e}RMwmY63hchx(<@(2#~x}Kr3+r~`r=~tm z^a7PlT{OJ@GZBH7rUyWLbDl&rj|ZE9zkFHxuv zsxgsh%4A%Wa*)h7?R6Fa!a6_TDF zNLuZbxov3nkQ0N9(7I!?ClLsJb&PE0iyC)M<}%vU&ezY8yEcUWRv~loSRbe$#{`6y z*2-Lw$hEJQe8>ee2<3rQBG)EDKQbefV5R(!s1RCer6F9fAV`J`D0nG|S|uclNOtw4 zw>^;FKcX$f4jg85)4hPa_q6KJXiA=TVYXvkdp3O~|km(Vyob7C`k=819)X;_4?4Kmxb`1A#@qvtvrqf^|uKXP-3R-4pybQ2`wwh=?|e!9foLhN`$adAl8F-l3Yl+1=btO)d3x;BIPZ!EkxBxNH8z zQNx4gq1Suhqa}z^L7=p)tqs~7fp5~T9yaTJ*BoOaXji4jeUBVBU!?|g?Y<+9)>o;& zfZIbHV_w3{IQAiv9@(Vq?yknh=9+47H8wjozlodFgHlm^VCXZBSj?T?69;*N>V|7I zCIc2ky%eC)&@huJtR_cQQxg`Z0ZmC*SeR0yQR38SbU94NiRzj#e1suYQxjJ6`0+J0 z*1CYTxLaM#?G21IwAR$Lwl*{{4a3g{Q)6RGi#(yRgVunL1|%Z6m0rCHYv_dwum+s8 zN~_9cDs7c37hD)kk^}9z(4GTiEuL`Bp>iEQv>(Tz!>*=WXzJjxcQReOhD_ON{6l64 z)`ULzyX8Qv38SzkWMEAgk2N6$YruG{0b{TRjK>-<0&74YY*#Uf?hg#@gEV4Ph zp)>Ije>CguIZ8RddDi8>m?&=c`rD*W;V1B`(Ag9oCtPLEiN^yn{Tg{c%`5 z>wi0W@+61qJw0=kn4%AJ)E14^q+$bAj?{H|EDv!xk7S`zPB@-~T^=0$2RX=75VCu8 zCRtO9BXA=%dnr3pD#z63_nm%!Fk1#Tj^szA2Ek@Px!S|Q(*RtqoHT^?2f=f7X42Y8dSI1jHyna5;wr2a_tauIi) z%A@uC;y${q^OkPfMguuydcPBz^gfk4rPUwEO1!bpEOab>pX!~LKA;16OR6uFnenoA zW)xBnZgJ{C8ItgR-g=Ps<@WH7KaML7(A*^GjsNhS2U3I+jE3 zyEKxhouOsakKt?bJ()uR*cINR3wV2Wr>+y~R!>)OYX!}ns;@NDVH~ni_Mk1AN?Cv2 zi{*REkO%6zH5clc29~;co2b)&$kfC@KyfAyC*Waz1uKbhIrJz zo6R5FPD{84pp|5}7ptrRObSW4VpPt7WJ=Lax=!oA+eW2F5!~-q|H@U<&T< zkj*j|ht_OnFYv=sx2+TEQ^5LiXx1(nO0%h*+jp@<-Vzq?mm$7G`p;M*k4eTs8RGtK zI>>(GQ0C_{)El9XKW8hrMTE?b)Z>gd`y8Kfoc+!NGw7tu>W5JFNp_pBcHTMJU)~XK zKgYK5!xDc<=IV;j@=I(Phf1$>Lcu??e{p|{Z^+OX)TO`4W^#YCDrAWF^9L)~GX7^o zXtNCQ?s`r$yCFM41%5h`kSm)C-oe=6&fy&{aiqQ`8JvYZgm9xh34hMSKkp$C|B6h+ zejR^-!AWp>;FNGGaf(Ru#3uuz+wE$ty<1gLQB{Zi8*6oyyIR|tYHMpMD=Ko0I^aMX zl-{kVsJQoM)!o`wm&=XARj11}*4DO_gN}N6d23Xpw!BGgv#Xoxnk6r-#>*?$Pg&XM zY4vQX46)*WjEUQeN$LH{82>_g-av)y=B<%F3#$ss^_^ z*XZBS>MnJ+HPqMQEl^i?uhgw%*z>fsv?~2`jcRobP{G&juB`yk9HPb!NFCBm4XurK z6wdA{BVHlp6|JqUjV>*PwknmER;%?;tu+P&V47TmJ9mC7siC$=UDeqtU1T@GRb6`L zPOdQ~qD$rXmAZ%vfAIF8&A-k+$v-M4Ha0fY6q9Q#DJl7;Ioj9PS4*)6uCDPN?pssW z(A+F&eSLF{*Uy|Ovj>))In!2KhU3pN)#dk&e0c=NcNebb8U;b53T(zbl#)`(+VR2{ zDPfXALMYdWKeZu1K&|Heo~Kf+z^02~gDy#~TqC9fw*T!-P1u@&LdBY@nwZ2|ni?9L znsWVGJcQ=zX6A9Py4GG>TV35))g&~xwdF9<*4*;{8&>?)u;(L&(XJqJ!%8JlLtQ&Y zsi6s!YN4^bQBYuC=HgNEYgP%3_ZkIN={<~FMMXoo+b%SA7;bFN#iONy_SRawJ~1@q zx83+lulsgs1BOnLFiJAea9g9strisZ?La&mT^R1B#&{W(U7nX~zLmM+kn8BT!YE#RHJ4>57NOynh3Q=Vz8jr_w{8e4cmbOHyUfKCI{$ZJ zg)CStLvL`wYT-1us8fi%w)-KJt`NKPGPPJE@=9PqXrD%WO(0&*m@eWXilkkBGKrUx z>waP*|C{-$Rfe8INM{qnxtX-^PH1(w_&fJ+R)WYki|hEd-qR&yx(6Y(Oa6v|cv)?i#>VNBTmbL(-3<8wCLbvoL-foFocq?itXI{trYkp>;aRn{R1;ki?s?f!IHN;xG-7LY*x^ zk{n)FxXc`m%ux~2&pei@M41^67JSyhoS7&s;y3yA!7?*Hasfl6U-@pj#>h|%m(P%@ zxv}6`GV}yOGiFKQJ_v1ECPRa5*iI~y0#VRezFHd0cOvQ)ne+~l7QZ45<=ltzC4QI! zP(ek$w2?1!)pnVyKSGDLOMQ8?OABRa7(&6HO4a-@>A#R6egHDQkY45f6@Me^;=Ahm qt@JHl-_?sU#7~a)l5~%6bX&13$bXDX_*I(H6aC8<6qEfa!2bg1t_2qW delta 5944 zcmZXW30PA{*T>Jy+Dhznw;23n6D^D{3+^qm5Bz^3tf{{_jmmS6ebf}LU zi@>JV1`8%R`0PD)d~r$Mrv4A``6&Q>P&jc!!Oc2#Er8v2{ye^*q=+nL8nl<7-Z-Hk zVf?5;S+lAEPL9M8wkw)Axwz!wCo=)gU}3Iv#O%=KUda=Z-yP=AquG|SgXjo##UHqf z`$sqLn&U_;nQERv-XS~9SIFE$y3@SrcLx5qW!QPOnzpMX3 zzsOLif6rjihXd&M>v!oZ^gHz1^xgD%`fQIK9)&!L-oay;?xAjiZi#M#ho^3)E*e1h z0xwtHSY4dXOBbu_X42ULxU1dO`0ecO#ogRN`%2sBK1!Ra)oR_fTRS|{cGnKpdTHej z6&=R9op9^kp*?`xUbm5&a!oV$aGR?scZ=36$6x*Uv*w(pT60x1Uz6>&)Xn5Z-OQRF zG&ob5=bA*fbhlpWOX_o)VVXIb1dZva`nu*VjW2-uf<{rlQr}f?z~6-$S-n$ZqkdO? zUj0lRW+T_arms6AD2%gl50XW=pY<76&=;(0F zVJSA$M|cs3e20(Ga>b$E!PX&DwMRA0!A2G7(95B-3Nsn)?%0jADYTs@)|kin`$I}` z$!3t*<}+=5&6Px=B+vXcap_J{NZLP|H+Rwl)p-5S59z|6NU-=0B#{`&TTfj6kq{Me ze)AiJD&mM4G~}iEOdEIm+yB_`enk{;5EW)sn>;$wBx%ScXa&A+uwV>_m4iwJ}Z10I(6#wNvF%5J9Lii z{88txd{w^rzTf#iH2KB&jq^L~_sT!pf2seYfSv*G1WX991Z)nd3v3^l5?C0xF7RmJ z-5~p*sGz<<3xc)=T?|ePo)%meVhkA+GN((|E{D3j3hfd)B6NP}=Fr-(#SuFru6MQT8sF74x$Bo*FGSi! zW<;)wydR~B8Wy!G>R{B_sN2y>baZrbbXN43=vmR*qHlF`?H152t=p<@KXrHLp4t6_ z?h9igV^+pg$2{(l)MH$aRXrMF9b+S62gDY}{@PR5b9&EXz0!Lv?zOkqqd0BcdvR;y zS$voHCGmUXpC*JRyqB;j;c}uXvCNcMmlT{dIB9v(?xgd{4#}~}|4RNfB_w51N_onI z)bP}T)Y8;tsb8km_lrL9eJ1o-+voOM&Tnl^ zC+SPlU-TW(cYTIq#)ynP8MSZseS7ZPwV95YA7pOo7trscEW4}`S$}5dXJ5!^o0E|< zEhp$n|33ZA{SW54=H}*}9pE#dWWb)hkh}$XPX@*eEE!leC~DBBgDwva8eBYh$FLh! z`&6&Psu~hhc zF~nS3A43+H^$kGMbDfH*N(P%K&Eh+va4#<{iVcIHUvm{ zBapR^fvk%Jvf(6K&meQIimt{ z4hy-c0I8`1a-|8#wWC04Yk>Ub1*Cp9kj4}s_q>7JzX7DV7~gw4f&5ho6b}Lum5u_n zaRzFa57d4YP^UFO+t&eg^9Jg!0;+!r)Kd%8ryi(p8PG1bfkrAoV@iPbYzs8m2())Q zpxOAGR|s?{>WAL|I?fH~MAS_>2DJD*(2^BEOVN0(u8yJm~@S*#ltoF)(EfFuQbM_WOW!Fx>;@nFP#d9xz`t z1`Y=n)Cer37+5GebVFgUW5D9J0gFe+goVHoOMoQ}1(uu!EX5yKsw1#8^zT~=EMpn4 z%(cM!)d0&zo`ahHGl32018m?QhKgezOgY5Ji zWcLjq8xDi)cLL-P706K(jciWRn8& z!Xl8DZ3MY|B*<%nLH>6-$p3i+a_){SkoV++e5euRqf0@qb_Dq}vKkD0H524oETj&D z-S-0dG1{Kb2l>?#P-r74$`()@wVcF75Df3 zp!v_{eA3RG_vCB*e*Pp%RzX{%eO2V2TV(|GFaH7Ff0-51&a#VDTTir+BfG4vC6_5p6j{KIKD;J0(=^ zMtf_?Dbip>H2i^3@ntW6Kn_TVhBgaT2&z^#lYeu^o{aK-u|-uWqnkVsb@HUVU)mxX z>Pb6u$9X;iY0z=M4>c%gmeE`rK!3AC^QBnoX=#X}YVKyVx2Sv4j@Hi6bQ>Qw8vkx) zEAnR?IVK4di{a9f=pTHTaTx+RAexpzUvN~CClF7xJ&(q#5Iq^)io(XwxxBk~y-%aC zVKA+m1}t00P>oeJiGIh++CN>W-7vuO>9pClr8?{5c{Iw#VzSWO7C(a?ET1l;dg}`d z-NsjAOF0Vu-fWgx*QwevF^DQw+plOdul`j36(nP*Rb9W0dR%~6qo3`QNvI7DsSZU1x8p=_5b!*5=)ig-4Os=N>|Bnyg z(y((a3x)y5oui4ope;4R+X+!)4gH#<#n)O@N3T&&?pSxb73qJY_xVbz8ik_@9s4xW zcevw*`vMse-M&w=If`zkA;jJ~wwbyze4O!AC~Uyqy5}i<4}0{pPK+-)k{!d@RG#aS zA_xzxwqd&Dyhf>xk57|Ge4o#MG_jjfJ)PnOI#z(`*fg3-NEwaS-u z<&G!23Y89HwT)!Qx$11JK>UzX^<+QrW;vfGP&7I^_GTR&5Jlt(q()SPKe=kdaDn(i zb9*=w9e!ehK)h6CBKwrr^}o@3vZYrk;|nydR7ks{_xe(n&2v0oAdnBD$c4;-_h!mx zjBhgD+%rC7HoV?>WkSW#;WG9M&%jEFoevwmh;tc3G5KeJnWVdk6_QddL=&$8e6 zXxG<>dikMnt%j}Utx|GTs5&6pew9t;$hWo?jjUxmc)i*?0`uB#QTLGFcpe)T@+z2P!Cicw3i&bakGx(o19o?Srm=+UZ@o0Zn1`taBF6! zWDdq4w`WLvPWig&Op@7vsIpuH>5Ork$|Z(_wmw)VeIwq1+k}a?_33R=Z(hFEDo`v2 z$hAtVT@gK~7BSnS%J&!P3trgKb3zpb_B+o@Z}aBMyeUW|`YpaG4dc<9?zZ}Q-;<{D z1t@zcRD3`+52dNX@wGrpxZ`W-C!R3ZRp!0zifEy$>?4**C-ceCBWlvg3nZelPVtn- zQ&ivXC`^1Rjo$Jzer#%k1?r0^H&_nhY39VVqJuH=EnZ(mn#|{lPt27x`3t^MCH;ho z_uuw@^3QzLXATgEZ;pKf9$t1bDMmQqlG&J;-~7d9r7<6t=J_{un`x1m;8{U!+Yg3+_Fz(u|Z4q zKKTO|oE!}ge@6D>i(Yh5I1WYh?M2zb+c>FCAb&(N>*UwG`kD6y8iHuweL27d+pV4{ zW}G+cG+F7x_}A_ZDL8E7;m1-qu?g&kr(Vju7*X8ULl*jb{UZCEHMt`q7MD~>Cl@Pc*cL@YkbIp>rg_<*%H1mc~Pbwjz!CqAx0IP$-$(;Ae)epp|Pv&|s4 F{|4V`38(-7 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 7ef5919971..714013ec98 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -117,6 +117,7 @@ object MaterialSymbols { val FitnessCenter = MaterialSymbol("\uEB43") val Folder = MaterialSymbol("\uE2C7") val FolderZip = MaterialSymbol("\uEB2C") + val ForkRight = MaterialSymbol("\uEBAC") val FormatBold = MaterialSymbol("\uE238") val FormatItalic = MaterialSymbol("\uE23F") val FormatListNumbered = MaterialSymbol("\uE242") From 568aa9b005d3c7a5b82eb6a99f7a0276097fde92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:49:08 +0000 Subject: [PATCH 154/206] fix(ui): use middle ellipsis in HeaderPill labels Truncated pill labels (capped city names, relay hosts) keep their distinguishing endings, matching the codebase convention for URLs and relay links. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt index 312ca0d584..068a06e7d9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/HeaderPill.kt @@ -77,7 +77,7 @@ fun HeaderPill( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.placeholderText, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.MiddleEllipsis, ) } } From 7e78204336f9ea195ca9ae11800a40d3f0780b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:06:37 +0000 Subject: [PATCH 155/206] feat: ship a Flatpak bundle of the desktop app on release CI The linux-portable release leg now wraps the createReleaseDistributable tree it already builds into a single-file Flatpak bundle and attaches it to the GH Release as amethyst-desktop--linux-x64.flatpak. Packaging fixes to the existing (previously unwired) Flathub manifest: - add the missing 512x512 icon (copy of desktopApp icon.png) and install it under hicolor/512x512 instead of the never-present 256px path - drop the openjdk module + sdk-extension: the jpackage tree bundles its own trimmed JRE, so /app/jre was pure bloat - grant --socket=x11 instead of wayland/fallback-x11: Compose Desktop renders via AWT/skiko (X11-only on Linux, XWayland on Wayland), so fallback-x11 left the app socketless on Wayland sessions CI wiring: - install flatpak tooling + the freedesktop runtime/sdk (version greped from the manifest so the pin can't drift), retried like other fetches - inject the AppStream entry for the tagged version at build time (the checked-in metainfo deliberately carries none) - flatpak-builder with --disable-rofiles-fuse (GH runners) and a --state-dir under desktopApp/build so the repo tree stays clean - collect via the existing asset-name.sh contract (new flatpak ext) Verified end-to-end locally: built the bundle from the manifest with a stubbed jpackage tree, installed it, and ran the exported command inside the sandbox (freedesktop 24.08, args forwarded through the wrapper). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4 --- .github/workflows/create-release.yml | 51 ++++++++++- .gitignore | 5 + BUILDING.md | 16 +++- desktopApp/packaging/flatpak/README.md | 86 ++++++++++++------ .../com.vitorpamplona.amethyst.Desktop.yml | 24 ++--- .../com.vitorpamplona.amethyst.Desktop.png | Bin 0 -> 16645 bytes scripts/asset-name.sh | 4 +- 7 files changed, 144 insertions(+), 42 deletions(-) create mode 100644 desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 8442a8942b..4236fa6955 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -44,7 +44,7 @@ jobs: - { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" } - { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" } runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle defaults: run: shell: bash @@ -101,6 +101,25 @@ jobs: fi chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage + # Flatpak tooling + the freedesktop runtime/sdk the manifest pins + # (runtime-version is greped from the manifest so this never drifts). + # Retried: the runtime download from Flathub is ~1 GB and flatpak + # install resumes cleanly on re-run. + - name: Install Flatpak tooling + runtimes (linux-portable only) + if: matrix.family == 'linux-portable' + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 3 + timeout_minutes: 15 + command: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y flatpak flatpak-builder + flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + FDO_VER=$(grep -E "^runtime-version:" desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml | cut -d"'" -f2) + flatpak install --user --noninteractive flathub \ + "org.freedesktop.Platform//${FDO_VER}" \ + "org.freedesktop.Sdk//${FDO_VER}" + # macOS only: import the Developer ID Application cert into a throwaway # keychain so jpackage's codesign pass can find it. Soft — if the # MAC_CERTIFICATE_P12 secret isn't set (forks, or before Apple creds are @@ -161,6 +180,36 @@ jobs: ( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ ) fi + # Flatpak bundle: wraps the same createReleaseDistributable tree the + # AppImage uses. The manifest (desktopApp/packaging/flatpak/) copies the + # prebuilt jpackage tree into /app — no Gradle runs inside the sandbox. + # build-bundle emits a single-file .flatpak whose baked-in runtime-repo + # lets the user's flatpak fetch the freedesktop runtime from Flathub on + # install. --disable-rofiles-fuse: GH runners lack a usable rofiles-fuse. + - name: Build Flatpak bundle (linux-portable only) + if: matrix.family == 'linux-portable' + run: | + set -euo pipefail + VER="${{ steps.ver.outputs.version }}" + PKG="desktopApp/packaging/flatpak" + APP_ID="com.vitorpamplona.amethyst.Desktop" + OUT="desktopApp/build/flatpak" + # Inject the AppStream entry for this build (the checked-in + # metainfo deliberately carries none — CI is the source of truth). + sed -i "s||\n |" \ + "${PKG}/${APP_ID}.metainfo.xml" + mkdir -p "$OUT" + flatpak-builder --user --force-clean --disable-rofiles-fuse \ + --state-dir="${OUT}/.flatpak-builder" \ + --repo="${OUT}/repo" \ + "${OUT}/build-dir" \ + "${PKG}/${APP_ID}.yml" + flatpak build-bundle "${OUT}/repo" \ + "${OUT}/Amethyst-${VER}-x86_64.flatpak" \ + "$APP_ID" \ + --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo + ls -la "$OUT" + - name: Collect + rename assets run: | set -euo pipefail diff --git a/.gitignore b/.gitignore index 45f5f1d139..2616f94e70 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,11 @@ desktopApp/src/jvmMain/appResources/*/ffmpeg/* desktopApp/packaging/appimage/appimagetool-x86_64.AppImage desktopApp/packaging/appimage/squashfs-root/ +# flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/) +.flatpak-builder/ +desktopApp/packaging/flatpak/build-dir/ +desktopApp/packaging/flatpak/repo/ + # Git worktrees .worktrees/ .claude/worktrees/ diff --git a/BUILDING.md b/BUILDING.md index 54b6021e97..e719e3aad2 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -37,7 +37,9 @@ Platform-specific: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset` - **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`; - `appimagetool` + `desktop-file-utils` for AppImage + `appimagetool` + `desktop-file-utils` for AppImage; `flatpak` + + `flatpak-builder` for the Flatpak bundle (see + [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md)) Install Linux RPM tooling: @@ -109,6 +111,7 @@ are **not** required to build Amethyst from the committed sources. | Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` | | Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` | | Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` | +| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) | | Windows `.zip` portable | See below (inline `7z`) | — | | Linux `.tar.gz` portable | See below (inline `tar`) | — | @@ -148,7 +151,7 @@ Where: | `` | Tag stripped of leading `vX.YY.ZZ` | | `` | `macos`, `windows`, `linux` | | `` | `x64`, `arm64` | -| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `tar.gz` | +| `` | `dmg`, `msi`, `zip`, `deb`, `rpm`, `AppImage`, `flatpak`, `tar.gz` | Single source of truth: [`scripts/asset-name.sh`](scripts/asset-name.sh). Package manager manifests (Homebrew cask, Winget) depend on this exact scheme — @@ -160,6 +163,7 @@ Examples: - `amethyst-desktop-1.12.1-macos-arm64.dmg` - `amethyst-desktop-1.12.1-windows-x64.msi` - `amethyst-desktop-1.12.1-linux-x64.AppImage` +- `amethyst-desktop-1.12.1-linux-x64.flatpak` --- @@ -625,12 +629,18 @@ State is shared across install channels (DMG, Homebrew, MSI, Winget, .deb, expose downgrade migration risks — **prefer a single install channel per machine**. +**Exception: Flatpak.** The sandbox redirects XDG dirs into +`~/.var/app/com.vitorpamplona.amethyst.Desktop/`, so a Flatpak install keeps +its own separate state and does not see (or risk downgrading) state written +by any other channel. + | OS | App location | State directories | |---|---|---| | macOS | `/Applications/Amethyst.app` | `~/Library/Application Support/Amethyst`
`~/Library/Preferences/com.vitorpamplona.amethyst.desktop.plist`
`~/Library/Caches/Amethyst` | | Windows | `%LOCALAPPDATA%\Amethyst` or `C:\Program Files\Amethyst` | `%APPDATA%\Amethyst`
`%LOCALAPPDATA%\Amethyst` | | Linux (deb/rpm) | `/opt/amethyst` | `~/.config/amethyst`
`~/.local/share/amethyst`
`~/.cache/amethyst` | | Linux (AppImage/tar.gz) | user-chosen | Same as above | +| Linux (Flatpak) | `/var/lib/flatpak` or `~/.local/share/flatpak` | `~/.var/app/com.vitorpamplona.amethyst.Desktop/` | Uninstall: @@ -639,6 +649,8 @@ Uninstall: - .deb: `sudo apt remove amethyst` - .rpm: `sudo dnf remove amethyst` - AppImage / tar.gz: delete the file / extracted directory +- Flatpak: `flatpak uninstall com.vitorpamplona.amethyst.Desktop` (add + `--delete-data` to also remove `~/.var/app/…`) - macOS `.dmg`: drag from `/Applications` to Trash, then delete state dirs manually --- diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md index 12bf3f3c7a..c732506f48 100644 --- a/desktopApp/packaging/flatpak/README.md +++ b/desktopApp/packaging/flatpak/README.md @@ -1,44 +1,65 @@ -# Flathub packaging for Amethyst Desktop +# Flatpak packaging for Amethyst Desktop -This directory contains the Flatpak manifest and associated metadata for -publishing Amethyst Desktop on Flathub. +This directory contains the Flatpak manifest and associated metadata. It is +used two ways: + +1. **Release CI** (`.github/workflows/create-release.yml`, `linux-portable` + leg) builds a single-file bundle from it on every tag and attaches it to + the GitHub Release as `amethyst-desktop--linux-x64.flatpak`. +2. **Flathub submission** (future) — the same manifest is the starting + point, but Flathub requires building from downloadable sources instead of + the local `type: dir` tree, so it will need a source rewrite at + submission time. ## Files -- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest -- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata - (categories, license, screenshots — needs screenshots added before - submission) +- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest. It packages + the **prebuilt** jpackage tree from + `./gradlew :desktopApp:createReleaseDistributable` (which bundles its own + trimmed JRE — that's why there is no openjdk module/sdk-extension). +- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata. + Release CI injects the `` entry for the version being built + (the checked-in file deliberately carries none); screenshots still need + to be added before any Flathub submission. - `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry -- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256 - PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before - submission - -## Build prerequisites - -- `./gradlew :desktopApp:createReleaseDistributable` — produces - `desktopApp/build/compose/binaries/main-release/app/Amethyst/` -- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` +- `icons/512/com.vitorpamplona.amethyst.Desktop.png` — 512x512 icon (copy of + `desktopApp/src/jvmMain/resources/icon.png`) ## Local build ```bash +# 1. Build the app tree the manifest packages +./gradlew :desktopApp:createReleaseDistributable + +# 2. Tooling + Flathub remote (one-time) +sudo apt-get install -y flatpak flatpak-builder # or distro equivalent +flatpak remote-add --user --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + +# 3. Build + install locally cd desktopApp/packaging/flatpak -flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak-builder --user --install --install-deps-from=flathub --force-clean \ + build-dir com.vitorpamplona.amethyst.Desktop.yml flatpak run com.vitorpamplona.amethyst.Desktop ``` -## Submission to Flathub +To produce the distributable single-file bundle instead (what CI ships): -Follow https://docs.flathub.org/docs/for-app-authors/submission +```bash +flatpak-builder --user --install-deps-from=flathub --force-clean \ + --repo=repo build-dir com.vitorpamplona.amethyst.Desktop.yml +flatpak build-bundle repo amethyst.flatpak com.vitorpamplona.amethyst.Desktop \ + --runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo +``` -1. Fork `flathub/flathub` on GitHub. -2. Branch from `new-pr` (NOT `master`). -3. Copy this manifest + AppStream + desktop into a new directory matching - the app id. -4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". -5. After merge, a per-app repo is created with write access for ongoing - updates. +Installing the bundle: `flatpak install --user ./amethyst.flatpak`. The +`--runtime-repo` baked in above lets flatpak fetch the freedesktop runtime +from Flathub automatically on the user's machine. + +## Sandbox notes + +- `--socket=x11` (not wayland/fallback-x11): Compose Desktop renders through + AWT/skiko, which is X11-only on Linux and runs under XWayland on Wayland + sessions. ## Codec coverage @@ -47,6 +68,19 @@ Follow https://docs.flathub.org/docs/for-app-authors/submission - HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in `org.freedesktop.Platform 24.08` itself. +## Submission to Flathub + +Follow https://docs.flathub.org/docs/for-app-authors/submission + +1. Fork `flathub/flathub` on GitHub. +2. Branch from `new-pr` (NOT `master`). +3. Copy this manifest + AppStream + desktop into a new directory matching + the app id, and rework the `type: dir` source into a + buildable-from-source or downloadable-artifact form per Flathub policy. +4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". +5. After merge, a per-app repo is created with write access for ongoing + updates. + ## License metadata The manifest declares the binary as diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml index 8ed6f86207..79f44e226b 100644 --- a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml @@ -8,8 +8,6 @@ app-id: com.vitorpamplona.amethyst.Desktop runtime: org.freedesktop.Platform runtime-version: '24.08' sdk: org.freedesktop.Sdk -sdk-extensions: - - org.freedesktop.Sdk.Extension.openjdk21 command: amethyst-desktop add-extensions: @@ -26,8 +24,11 @@ cleanup-commands: finish-args: - --share=network - --share=ipc - - --socket=fallback-x11 - - --socket=wayland + # Compose Desktop renders through AWT/skiko, which is X11-only on Linux + # (runs under XWayland on Wayland sessions). fallback-x11 + wayland would + # leave the app without a usable display socket on Wayland sessions, so + # grant x11 unconditionally. + - --socket=x11 - --socket=pulseaudio - --device=dri - --filesystem=xdg-download @@ -37,12 +38,10 @@ finish-args: # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 +# No openjdk module / sdk-extension: the jpackage tree copied below already +# bundles its own trimmed JRE under Amethyst/lib/runtime/, so installing a +# second JRE at /app/jre would only bloat the bundle. modules: - - name: openjdk - buildsystem: simple - build-commands: - - /usr/lib/sdk/openjdk21/install.sh - - name: amethyst-desktop buildsystem: simple build-commands: @@ -53,7 +52,7 @@ modules: - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ - - install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/ + - install -Dm644 icons/512/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/512x512/apps/ sources: # Built artifact from `./gradlew :desktopApp:createReleaseDistributable`. # Path matches Compose Multiplatform 1.11's output layout. @@ -73,5 +72,6 @@ modules: - type: file path: com.vitorpamplona.amethyst.Desktop.desktop - - type: file - path: icons/256/com.vitorpamplona.amethyst.Desktop.png + - type: dir + path: icons + dest: icons diff --git a/desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png b/desktopApp/packaging/flatpak/icons/512/com.vitorpamplona.amethyst.Desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe89a16f0378d0e21e8ab3e1c8b2d46b4cf0b52 GIT binary patch literal 16645 zcmd6P^8l^-s7P&WvnP1g4iK7rQ7$P zzFD32ePd>uazb2|dw`8XUC}#Fr*ir19dwzxk~bw~PVnp^7cr2Y)U#S1wHKc zWn-JO>_YI{BMQ2jv2fB?A`#4CS=rCn>B_xEYlDJpme-K^)$i*13b%ZlE!6SmUbqL? zl6UWMzUhm}_5VQ-y~YJFKQzQf20>_&EbviRfgb!#6$YfB0Mi2>dD%~8L#70SpiDF` z_()Ip|MlR6Y|>2kH*Wn2v-h%k6OVfhPQDt>Ts7XGzZA5Z!naxxp1}O8yto9PFsWX* z5;&Yu|3nirC(tw^7+RX>K7J1G!e}_FQ+la@}Kgc*~0>(R&72o#q#_SNY)3 zq7{{ZoKW3k79_@${^@qyTRjn9s`$uyvTES`-k-PAzcYEaVispf_&(d%-PbVaEjv6N zgIeu)ZxBIE|GIW>7elpexpmuc#mO$F#Ls!H5E^rI?U~vC54%=B!ECcf(!Z)T?LX!^ zx?bGGw*I{e<0S}T*Cr+OX=6$cnKu+zGrT5LtB*_ksJoj-3}I#3hlKRnLuQ-&!;-F{ zJ`$@DXVdp{Gp zAnFOcx|sPreI$()UDf#F^fXNrcqfr;tbsj67+5N@C0J4kAZs{E1V?k4y*+&!R9e+zyoK&k`_GU zvo=|0z65?y06!R;q;LfK^fO2@G$~vIE4}*rYBbEj#RbkoPQ0W@szbW6FhzhA&QL+* zIY-s!V#LSu2BguyKE$jY>d;0;X~Tw?$skf|+&B3fEIM!af_Yh}po7)65TV(b+~X|I zOl>9<99@$7HE<)nH%k%PQGzKnr%a~s<5M2ecb0jm?ne^n6lC?Fe&Jq!OUZkAoq)QD z`)JBflf{H=PiW^382?&@`{MirJAy)2k$@fru12 zX%+{V_siU|@T{3fNf>!iIdQB{uErBdnK{7=5w1Gpsnyb17JeRs z6H>qL3mHEE87_M0E`|q=+#kWv0NGMMgrnE0;D`It6hMUUUXVa)_fj3}{-B_d4l~WO zO0D0>*c^ev*-@X69(;LtI{NQAY4o7DS&I#E*c* z`Yh}gE-V=50tT#fA*zD?%!rYA6o?J%UMe&m`hvhv?y? zyd?2BmQh40kU8N$vdm9W%%djW4nVi(=t3Zh<>UV*Cu3gGz_wqI^na`R?G=NmP)5>Z z-o2p1NFCOb4c$QakP~5E3+1GHf2yTrR2HF;=Oq0tJgYuH&*aWyNqMinFI$l;j{*;S z_{H0rMd?2047OLv0fVN=lxmQ^QLunD3GGChfEgJEp-@M->Ah5XI0@k#S>|mC=Zf7k zYH5J#tDast+x7q|n~UIa%1t6=+ZKEmK@2@q&4>K%z#c2)2dr$aa?eU3C_JM3iXVIr z7w4u+Q35|)(&@V_JgdM{v)%cjn6^O}hzt(a*~2i1=$fOxMN*#qrYb2PY-Jr-P4bKE zk#t^%5CRKiAiBSeHL*VzfrBAcV2?$FK5VkZ%C0jGH_coFDv#BJm^wNs%F$q!0}8QjRPzVRfTxQDh88M%H2FY4zw&bJ@^b>qWA+}-LWH5; zsTPw_DTFY5s6oEVsRZV0L^?PJEIZ?NUB?;lgIlN7>L^a*AeN;ed31W-_^vMtDk|YN!J6 zkRB^@C}BCJ5?Y{CB5rGvUS`V;=Le*9&FIkqY3JdYw~v(ZG6NjCH<^VWyJpbANxmq< zItt^z2JWCRa7HZW;>lWh>sp=lFMqkZEw)h|a|(6IYY6&RoTAnu?4Ofm6j57`Hv^N7 z>T?o>EN_@fxJ}lbe5!xbS1GjUgW5+8?+9&P3L<#V3Qd-SjN@T*IWDBLe$8im&tSIH zX1c2KSCcv0>^tVCw1=w)jjXJesgcdgC477D(l#!-AjYPeuP$Yi`UQ+||8Du4z*xqV zPP#ON9+yn1?)8i~y9NDCUv}&XjVsmN>!#I*ySs-YlYL$OW9#LUdkwQCzV$QJ)w9(c zvQ2?<8GZo)BMxllRh26nDTix1#KqfNWJ8Q(?4j*ZPJ+#)sN&hq3gskMU1;cM#FIX< zZ%u4d(yP2XS(TL%zPm33wME90Y^t=HSuF!#Mg+X8mY=eEV3Nr}Q22dguWlumi7_p< z*oxd7+Kwk%36o0?o6Tn$cp;{tpUc@$$CCwdqWiZcl3$S|v5NU?KAqR}N3p|CKVhhh zp@~8DB~G7NRJ-tSD5H3Z>p?ejuK3iGx_a+kBiK__Jgw|Nw)$%LsOt4U<44PR)q6Y1 z2xgyyp7X8Fb2JG`{@phW@D>>7=^fFcs@a4TKFEAsriLU_dF0`G(7|U?B2Q*C(5M@} zvk{ATy+z2Z+{juqN2h%kD`2JRPnEWM%+=G=g$B7gB~5l144i8snCPMSV+*^z`}pnj zokZ%OoMPwC_+Yu{rhrEiK9ccCyO;`dOosu>ox44nF3b6O5A%?44b09I%3R7+wM5Hi z;!e39cB>>ngdRP-Tfef~9aL&gJQ@^6{dffPQi64)4DQxGO&i)4NaChP-@b&-Rl_{- z`3l#!7+{x>>f;yGo|zuzm&09TTLei!@q=uVeh!DGYpK@i>*=U&K6&ORYkoJ68YV~0 z?=x0;W!d=WPUz(g2uSy1_%=?w*Q1SbOu`eHxXTpS)f%6O$RzisxFr3o~**e6S}` zot=Jy58gXlaFI$O`i;+@gjV9o?hiw5XH6KvFC_3X+PkoPnl{jFVg0!XoBTRO_TW%o z;>wNqmumFrdDeghc{%rFGy=qW7T>K^<*ynOHxa59wN5;)CmVqQU#?|j_zZs#SKnMU z3v8_GxzAe>;^TyE?Mg3Trcx*wGafkWQ6#Ii+C9Kg*41hVKkRCFAC3Kv55xq*vDZj3 zZ5Pvjo{rRh)3B$^ChCwuVDYuJtDf@g8%oOdwDQ_2Ihc!2^j_g#k{K}0O1YLFSe-&3GSR5%cA=Q#BP7-7Sc=rI zl_k!b@!6hpNz+~V#1+pZd5J>Qd9ASr|Sbhm_QoRTeu6ch$cL zh0z3`_|LtvmRrY94@i@skTD}{>u@+pR18Tht=X9PG=lc2bsUIqeH6MidK(VkK50? zsvqnTSZ>dxHSG0fXDR+2d;NSUL?O5nk;R#b7}=Y7A$FF|-|K#z z%e5{HQpEotuy8yTREPq^2-~tkfiXEcrxKP=3@s%V6X*&`zLxIKS#nRaVL6|V7h0RL zS#gNmhm)~~@N;Ke4IgL@+1jb9I;)Ybn0e5198I9+Fk8-H`gHGG0fI`Qy~4!l(P_x> zaJ=nXQxZcHJV9VcnaY2bPVTm!-(v$}kr6M%xP`aKrb*iVL|~Ej(lDLet_Dk&WUYUE zBqP)tfjj@X@;Et&IPf*gI`e}rRNj7EM4*(O51)Jhf|ApT67rcyx(GMj0}z#B_4n!t zq+;+3Sk8>2PwTVd$3fZpnN{j8Ix6;*$eytg-}MNZ;P%_F4h&6i?EP2KaXQ}0NRpwM ztLOYx?&;+}P1JvI8Wn$LLN;xYqN7rsNIzt>nb@)8c1chm$Uq&}3k7KWP4R5mo=WcH z75@^5;C2OAN7xrFR~CWM1+do69k0tLd~|~v1LsK^?QkZ9IY-^~4kpHtn@`~Ju(3DLbIbAz$-8zplr zYO_(e;G=BFriITR<>enwb@)t@-leXvcy)zrKzj2>4QI(rIsQh#y@}V6I;siYI@6f@ zLe-d}tGPU@#3lcX(i;hDJ0APH*Mx4}^duFSUb*Cpe08^Jy~HwA=AbqRz88SJFvm>B zPTCXj@{O__`P+ND7xak--(M@1Oe6K~?Pqo6iuX2@dUj;bGp(*I#vCVwx|$;JC&#tI zfkz2-XI3eEg5DFHesC3LaE8Nz87{O#XPjmZ%i}z>qH%Hhv{lpMJM#oOV;_95<)ptu z-L`K$F8)R=%!XS^UT~>DqTc-XUGEaDhkn-(cgQs->b-C;2RLF~>CskB4xMV4r&noT z=3YOX4DCF$Ky>m;8shz5mZemhADjqT`mSWWOK{@7t~C>li-lz;7A(Cc&~->rDTw&| zyq6L3&~GQInuw4=s3c;m`jij5k9fw>ayxrG?t@=sEH~%c_@6l4H8Z(@<@it2JF)QF z;vEq#dbC3Ry8DI@W;YBlFNMQS;?KjczQ>LIe75KRj_t8k=+WULm%8tqmO*}t{4Sn! zd<^v!Q{MHJCx0@*+`?1}>{9m5>z@h>vOPSJVb{fVgmj&?1*0@=lSZxTkL_@dDFRO) zHN3Ry=Y?+DMbi)-eTzQq>+C7mE=sAId?8aGaPORt=vwRS)3tskm!RJ3 z^OP+f3y2@t#anU=SkiqZ7-f0QTs(L2CGhgSL9YSE@}9x%8^Z^+tE^Q|D(j1xE7z&{ z6RyjOBhGAwlhCzY5dGuoW+50eMhGU*joYb?K72Cv-L&_ORMv>hl3v!iJG*JdlD1|} z{mo<^Q6zNVwq=_eGR$O@7dtq`5A94RpQ|IIi&E=a$xPLoyx`uIyT89IuN#CO7xB1L zDM&F3-))cvGli1Sv1_$))PGv~9gWu-;1Cm3TlQ{zD)nEjB#OV7?ZfPK&nqD?Q7d{h zBJJh^KB_nBz3(}kqEAoyfg_NXhPvEqvLJFUuf}SN!(Tl)QCD}<-2DT#HE7oXwz7W* zR`4+|ZFMhzfA_R%xiXMRbe9RfV5bAiW6Y>jh_JH2f_@{ zy`-E2eYnrwSHzmrCHtk7^yKBXfnwUzqwfA~SV8unH?fQ3^k^TaDuk*oULB0c^0b-8 z=RGXFyHjga!u`r5BA64BWwy{x1>jQfQ6@AL$Gm((YtR|lN<4NO^jvJ_SsF(m(elTB z6GD2U+4h$eN>_>&j6WLjoj(R#$2(XzAINpaL;{^^PWPIP%+FGsd(j=J8;YHp_d?+N zL*i~9_J+`&>;_57-U4z2C%J106=GUHuJVLD#B1)ANu^COCVEe?FJVobXLHo=?K(MD z9!og5EDSnl)U;C`gp=q}6#q2oc4`0C{7Fra4<&$5N;lgI=(aTP%&nYg(vUjZOR=^n z8r~h9%RM(UC-L5uVjX|*+Z)&#>Q>6HZ}ZBnF^S+17#&bv>%6B)L?K@_6kZp!yt?$_ zm+LrD*Jb@r^)J`zpb_IbxGHpD$E{)Xgc_9v4ZYa1Vd3&wIH7GX?h9I&j?M{qJr`8? z!|n>-hIIz9XC|olha`o~Si1%vbo|i!fS|$p9uw8bcfuaCoQ4ef*Xx?J(_@?B)xdb@67ZNv4Y2Qre#gy5o<5z8k;80pHH_XeJE} zguYkJ4O2yr>B$lA-Dd;_U+=@88OOf9Mz)bQTjb4`h`zP=Cw}httrLH~BV*2~m0~M1 z!ha^P?6+7c6fu%zzQj%Lc6}F;=LQ*+@1xlWTtB4?b}OYha4&Q*T3m-#jJdxCZP@#y z9l^hP(zCdSDzt-9tVIzZX;HsD;oj+Mh%@C84h|CAxP$*vxt&1>%AetjGTXL7hN)~e zQuH0`d2SWOR0;c9vlRxsR-;h&%P=^Jo=C%3c>VBRZ9Ou9FGI3so zZ3-Ek^EKO(J&hNtwDGo^GU2QBkDqS7R>%2}ygcypXw8-U!l@M1ND{^Q>glmxrQ;JIq}1 z&6lwps;T#XC0||A7`PoP8wo2ouMq_jg}ptSRjmc3PzA}9WvKy1kA)u5QTa)5d>>bY z6$E>FJDN}CsDW+kKEhR1X{P(DkmrOS^CJ{-4N|Dp)eW#WvS4PTnvvhYLos7HtM7>( zur=}*Zf+5%eXZ5Od39J9a{PocK|W{kKi*L;ebFbS>g~Mwb@vKaBN(qUCE>FDV(T<} zdGZ#zKfY9NE@FT$0pYG|di{+|buK4@qWpca^PMlY^1y1P!rN-Nkie3~G**hh42eeY z65E(5jT|-=`wnJL8LubHgKtvX)alXusdYZfFEKnyiya@TuROJNK~maemg@gmy-WlBY8G48@eB! za$Pj-JeG45v%N;3?4V6*f@3*CtJqNAIVn!%rwhHC8=4kAs?nGPu2k64Q6u$cQAD7? zwIx9eg9t()oLQfQ0~oJFxcFypUd_&_DmLvP00(Vaui1V*(k_?D@|=rdldbb&y`oP- zmdOM{h8qBWR2Ms>Xo%L|?rmwbU8*L3XU2c+P01PXJ0hnLW8~!jyCG{EIKGaIuT7GH zO&T8omHtTQ(g3TcNXoV>i276~F==@q;gi_Pm=QgkGR(GS&o4qYaoNRC_wXz?$o?85 z(1j_(7_z)9PI}K{_oTvd=D5vGjjeTe9ig zl#S29wK5J?e5Oby0VHp$e#wi-_^vBU!120JPiH5wo(SZ6Kg&@^CLWck$yCbKK15Qr zed~3KP4#hv+u<33F7~Z{B2YeNk*_@plg0!IA--k~2gS|A&F4kyFE<(5kdRr$j<6cb zY2yK;?oskcZsj|H?(y}QKVR7K|?qlA(_qp?7Y)+ zZXh&9a?RK>V|k&F*g7nqP@%NVLKc*n7wFM>-muH7AxA3vqfHuI@<2YQ`~e_$h0F?_ zy4Un1M-!ZgZlFi2*$yW@4si7UVLhIRlfQePQE;?@a2@q3jaFY>z5R8|v$qg+KO$ll z`xn^qT`Z?CH8Ht)Bckr<96p=)`;V<^;8YG#2ahM*VwAA`3+`~}%G0BR#Y{XTk`EQZ z>FWC0rmC>?=5h(rQmy$FF8((2G0Mo}4w7<+mdpyuTRX{(QD?e{&E~j~5Xb_DecArQ zt$KVz>&n`rO@XYBMLbV!;yQ5=T4+C@QfU9rcgKJIS+DZ%JF-Sn(vM!WOe-S{M8q5g zse4t}1cl+^FA1&Ib(6)c0sL_1HjI$ZXf0%-Q*sfkmL%Tjh$7|pSgz{SN1N5{R*X4U zAtyrTvQg)LranMe104;&7Gg$T;RY!0p5~K)Ew_ zdt{lL-Hj!^M*rLO*;1{G?Ei47?}4fFw`kL9;jwb!RzlF)_POQVH|yS()!IJ4wguN8 zHj9~73N9Z|fKTtMhMk)Mb|h=FR$i>VUnke}U~dN}B`G_&+am2x)lr6$_v!Pu)k?}Q zIg2?s4lvm{TAr`W>aOw&VlACAnC;7dFj+3-L)h}2vNfGvTh5zxuI|l{ACi1XVrcNh ziUmfXNXoc`eXCcqqzVdQO}D%U{>aPjCbXu?esITFgyAlJ3u!*;wNnM|##s+Ka2p|v zXq{rw*$-=yPeP}jZgz*_nB^SjN?hc$@(dlDTB!v^T-tYFOaL#Xh39TEVqPjQKBm)n zg3Z+dH7W8(F5NJ=PT>A;<{8!xRx(mD8u;#klwWh1=1oUqAYagxp+`%I#Mj;c5wlgK z*VXB8I}2c;Rt;lf50~OC<>n@?smdL5)W#$&*Ag@(?9$y1KE~-mBi|xpES?t3v4KtG zo!T)1AZY|TRN%xM+D8vN9BVmmYZ~g8O-;k5ekVZ_ePFOuj+3; zMv`Zm1dM+vTEiemEf)LspKD%Gn~=C^;8i=KgHX}JAW=yA`)sO^pL;v&l?KZT*;enb zC0v$g1jU^f*O~)5V$u&bIjpAuCfiKor^xswVfDf&#baH1w1rWqPW*kqmxvj0c=ucW zY*;jG=vDpE(tk9ym7Owj*O&4Lw;ojd6uKhYQTl7 z?uSWb)P4KLx3J`nAvf?7zj3ZipoF$Rj2Du9x>Jl76>>h0&5p|n*MoFH_1blkr_J%>B?fF*|ngjhW$5yo-CoE-47sL2L0sdbv04VW^ zdKLxiLUn5KHZR2jc!cs6MQ|Gz$!vYB{4c2F!B~c;v4pCIp+}) z2-R%&YzWe^aO?0~>)M&Px(3`@oGyegw%>Sm79$A+_gH<69#X)IUt!r6j9=V+mCOS# z>sF3g2S=4*VCil5MjA_ks|USiXX!6@IBbTlcOD4gOpj5{c zm6q=4l%^XFs%&2diS8ZQ9>+YumDg&s`p*w8t9)(g*Mkx@@BtZV-Pb_`B%MxSjgA&M zvwYob$Xy-aIdC!sytGVZ$!cCWpoWpzA_lLS2`j5IP{n6B_at`7QlWElbMZE6Ecka# zG=f^`z&+SRr6dA&m?i@kOXUtW2=ok0RA?@7gOm4UNr5wxd#C4Ybdmc$`2gd~F4>17 zR=7(|kS6i*@{CYaG;ScQXEhGgyLnQ)!=pv++c7mS=juFR#FB;>{vlzPA}0=qaht*D z2rl^}#;tQzI>=~#-Us|k6$CnlhC5f#`g=#4DneRexPeT3F8mS1-rrjgeJ=WSg*!6( zSDC}4#eU0gz|k8pH17*MUxNuK(e2|=tUO}T6%-(^e;65Qo3jkV=_#j0n^mlV+)L!< z3qimB!hJ&3gcdno1;mt4?|#CD znpj^sG>m@9R%tMZm%hT*FfqIdYJLLwuGh!9=4H4n7`Zi0p71vsly^BXpEz_b`x-lV z#0a1Kwh=d{!)BB3GNCr~>*a{!ANrIjKDE$SK{T4FFs>fsit{+)59UuA#U?w)+Trka;Mvo9rn_=X9&!D8qa zox?XEEh4h8Ho*7`C|L2j7XR+6C(1A}-@W|Y;)7?x_2G55e4O`3D-ZmPT&A1Kj+)+A z4K6VYRDV1OOmcodh((~f{l`js44k_`;R2Km-Z^_smV#CZ+LZby@AaE5#q{M^2KnG> zC(pj9B>H}oPdUiW#Ac`bjdyCx1W>Na$!m!XaU| zc-X?fZhG{T6V3O{O|?(-Ose)8|134m(0OFXszTSb^3*LlzFJ&kp}L+)D(|(JIP(2# z=Z&{dI$1IH4W=U z@^Vd#B!dj?%)9Q`s*T?;W9A=zsh)T&oFFN>)OxSn)VL{4L(MvH!Ls+R)7~JrITQTM zv0;tWr&F|hXDX%ML~-pv8A(3$^{u|Wb4|sQYdo8sVv~c#hhJNJChy`^JXRO-E-fD1GRx8Y0>0s#&^f}-!S%5ZTp26O1Z1bA;E#N>?Ju}E0Fs8^IY;uQ+;e#(e7v;s z(Q$}2sX44ZXggPg7|p|JT~XaDIoP;u*8x8LsgC6pS1*{0vG!6ZUwf&}-~~8_SB#hAg6K(7`f~ zyQ-@t1>o~}XKlx4*2HGEL>YwA5P3V}iTi@}f|0mq65zEl_JIu{fN*4g=RTdh}9#fP-rMZVXQEOgJyJ zY9>>l8<-Ri&HVmio?up?r2rLwK1)!$W?I=Pw6sO3D`)FnV9ri)JPv&zLGJ1 z*hyd~m~&8}@!LHFO67-zL}`Q~r_9moNx3yUVqW>GaA&}m{6F)qYa>4kJlN68ET9lV zEzlMA=j;HCij(h!WkBwEc;HMzE3M4DsZ0FGsgvqR3S9mGbB)0#;o%EjNJy3e z`<7SbI()|j`BKI6kFO!kUbW=oi-x9(P1HJqDNJyZMgb+*T}?wJd)_cIsXkd}cW{aq z3vA!X-d-Tz>ynT|{3l|So-SSDWg)69v8c;I)#BAxee2p%{(v+US|iwCU_jr+B){Bk zDCD#qgM>P|k+@U&lI;?fL>5qv>_dL%m z@l5*{d{FBZm*3`UK~cDYj-E;`@Pd&@z(xW_?lP8?fj^#RYNPzCPb4mQ_);EZwz2G) z)PV%G0hI+F9MSO8_iMAeI+#4w#cx?D1&Lghj022ib)V@WwQ?Gi;ysYFW&faT>4%H% zn}({_f%Yj~bMJ{NL%Bv5Bo*>O0g2mmgkZDJ_%R3z26tc#uN1ZG_K(+2;A**q>lh;k zXt)9w=A8kc0P{=lK<9~RmsJas`(oyXRb^Eb&wS1;ef+_IF*?H6dKl{CKEYeGp z%y$DHf>9$ybDC#+Vn1Jk)PCK?r(kA)SRPLe(_=YerY7ZuM|nex&^O~K%p_C5+&>=z zd*72=nI%3KC6Ul~QC^BZ%oyREKSE>$@+K34W&_^NU%{|~Y42Vb^&4Q^w3({$wN1(Z zk=QVDR7GoS!DJ6>4GIn|WLDDAB=_SYNJQv3rGj?u#?OG3kOC=Ba#Fk7Z~s%c7ubiz zoG@UsgNvNn7u!$Ndtzd6(3x?c(C&;0353$Xr8>w=D#)+C(=`$|pny>eHLul1hTUQi z|9QZZ#Kwvx4a3xUqR>_^gdmK~e4Z0`rRw5i`UshTj{x;3TdHsMj<2|lq+ySo?36Lz zoqIVT`l{$R*|)lV%Rln*)0eQ>dR$^Shdo71V!Mhx;o=~qXPP~S;6^CLfsE!Z=7PP5 zRRY&SnyP&VY0~;-ER53LTXK{lOc54~JoY3WgxNHMJcZKiELor^LHYm`%&( z^|_@$yO>P~o9F??zUq~>o)V~SmIQvV4X$?y$Hn;w%>rs8@LT&;dOvC@iem3~0ZNd? za|B<~pi|q}B8R90^JP+#kN6+adf;HB>7G&k;IxB5^wmp}{!Xtg)05GhP=I<(nOvkH zk~_hQKK-t|&bF+r`$A8ixwLYv;9`~{J!nT^P7>vIE0_=<(!n{p6OZPx%79wW#_LaL zFBKSJVbz)V-9oK71{Y_~Gs}liOzcfZtzA&l0IeD+kE#x}lVodEk&&P^4LLX>`S$8K z(BaLV9`8t8EGqFhLRS<+x?=|#o1TLa+nM*@1?Znv-h30(!ND%R`2rT|NS|vPNV@gG z0r@H`6WRuwC0MGF3Sj+G&4arDm-^$3M9Ya%mYq-$#rM7^>i zL9yh~!x#yXCHvDZNsrNSNhbq7gz9-+a10te3PPFSk3e~?-6~xo6UtPLtdF%}8{5!X zm%oZo24hBAG-tzbv4#s({>YctKaxdcToH$%S#v>Qt&D~ja{4{;yRu1O$rV5*oo`6W z$oHniQEzjufwddk;BT{?90ZJ9EEJ_`VXBbbSW>pD12b!{B3#Uzq!CJawX*c^hfz0K zqHY=_W-!9D2EgewHj#f+T^;O7|3q4X6w5{U)ou(%mJSQ+;7`hsJiu5M0WAfm#RIpW z;)24R5;6!?R$okH1TNNgZ^nCb3ln&1buTh7m(dx%_REGZ1wmI)*69nME`V*+eM8cJ zWwbhr6EdE6SyZ-vYy+@^nvL3^6wWP60KmOP(a0ZleIMW`FIs_DYrIohgv1=bVoRR- z%@XLMUXk?Eu6g8kdV%FW9zrD8^nu!e_nr^#PrQaaz#=29zRmwizF;H{h^jaK2|Mcgf?W8&>eFa+MPai6`{%r&T)gi@=p8pwM`eXoT0oB%BUVSgvHFf!hK+p zi@M63nu@Ve2f;-N#sC@mJwU#k5?z%DJle_5vbHe>K(De#vp9kx_JNR$XU|TbBsihp zphv?_mx8ibivjpw%0te{q_r#XtIL{QGY(K*&M=$&*36Tp$9nV0U@b36L==Rq)xRm^ zGO@uqme+{8d|Ouzl77IoYR(r0?{qaFBd0J=s!ClR4c z^fu!x1ZD5^oOD)zgD$T%qJETW`SE~-n9KR5EoYbuBb2ipq$YiO z@Ts?4oFP^xyMx8lzwJjC-vA#I8JSgC&~#m{h6vqp1dSdm83_5CW%Q@b;sFZYb{ zGr0shMC#m6t}Lkgah%Rsc=KF;D8*!6!`u62JPd^CDjBnl!5G!h$7kr#9BxY!1ZrtT zfTr|yr9wSwrSgFb$3HIUle|Zhh@8p3>@&r`v`S#9{XVgrqu8`@LSQhi);!u&AM~GktEDvH zty(4b$V%Z1*sIHH&8Qy&n7|8{(Q$$u!EF~yXhrVQqa6#!b}k6i>|Du*9fW^Ew=mTr z@woztHiotjscF%mn6E*E~84S9dXzg`5(VzvlVToS=kO&6;GopX-D~MzF<-n zSV6f@s!D(8E5H(oSIF(zV1P%$mK$u_4e0AY_DU-6Ua16m^}>WL2a6B_l@ZK04jUy`AE zN^&|cvi4+L4@mSGi7u)5BC*@m)=vbKm1>ln)+xlBB@m>swmiknZiS~MUtv()!H99Y zL2~$jKN4&aKp@?v=lTIQ+EIRgw-5w$P|%1=+3E{YD7OUm{$CQYnLuqUwK&ollyLNx zzygr5itb%Q;*(M!DFslv@u}@B1!0QwXN>%2YB7+B<-KMJm=Oh3n;^zr#>NXtRM^x^ z^Cr5lmSS>o%CZM#H5J=JNhhZYUUQah|6njpIkW?`Z=9PxAuP_m9`^x$`lW;8R*h>B z9M0LjFcUEGNFQ4w{pp6n+-TYEJ@kNOgRCk-|ydUVU1wv^g_N5ic$RSh{^`Kwu zUO#yio-|L&vid#A}%9CIFNjrzCL>7c_lLoV5SgIKq# z>67?S%Vw?9dbR9vQ;v^g6=@zUOSCBdb=NguqgSaEWTo{DuRXgagj7}wzN~;cpa`|&>$S^mCi6qK>)Pu=kw++tGS-4hz8QdbIX&D9&zO8O=icP$zB&yezf&esScG`!M~$5M7Rkq?QG5TL zW(-Xn=)8l%m-t27{n$?mD=QObhb^Xp6yab6OwV*C9Y%s^Bedf#H58E7EER4MO&hFe z+q{M4w$ef8lFd}lJJ+~ZuiCfLcbkQzB@s#ny3n&4a8-dc*5n`s=Zqeb$Q*`Mu};-JCGptsg*v)&^OT;AviMpZW+jGF`1f|0Rz zOw|g)dFp|xq$gv7K$d0ef=h)IU^CdbA#o{fud_TLyl7r(f_Q z+a)Iw2qiX}gLMh;XeF==@It{ZfeRml5+~sx+ULxvdAI;&_DIt3b{dxKxS?=^H%X;Z zm@&{18T}aaq6iUHkT1>LGw{H_Eh~TRcz18yRZs9#B#~)2{(~JDFZ9X9_m^i(%C;F$ z5#xMo8$Nf1a)B`xR5=MR3)m1HHUZ zeI2<`=*-Ta_c2S{X84WiwPt~EU@T4C4yq<&e8HgWjn~&BilJfHl?UZ<^k*3)-Ero? z)xlyZc;v0$r);SxErQD_9bsu12P>uKySjjV2S*G5f#V){+NJ+Vep$x@Wnb5&W~Mc* zI5|7OaTRoowe{4TW&>;>wRk@@zwmAbYyqkG)>f9d5@@077|{`WBe*nw8cq?hgHWS2 z*zX{-i5<2QRA3B&Wk&_HY+ZI$o5u159~%mp9)yEDtMZ8!lp#YnS4IQ9Y8(8lbv6gG ziOgM!!dwQL9nn^4hHk70_h0d7qL59sTbf`=|I}A#TeY!C1Yq}ZBGrdWNHE=627r_?I(&(FAjjKFCCet zY=?{c7-gFpEFtwNC%)#o=FkekU|i=;#ev}xNQu$1{HHqZRN&H1Iuqcu~YNV zoRrl!mU_$9rRJAL?pOER0nN+vJ}1x{8Ro~UO3}jf8t*h$rPVjP@-VDwN6u~X(7!c( z*UG%Pu6^%Cy7kVH7ITMxq+oNS@aye2T?v!xHCsK+JBMpzKE!huEv1oNK8tloD`euD zW-9j}a95H~W18PL{Rosj!T8Yzne0ogBPU&&N8hhDZ6!4fUNNj+f7k54C%lcET=Q9( zKLj1v{Ef15%SU5%Hk+(2F6KU~=C0qSgG$U#24@#P6#C~R%!;ycIM^gb?S0T&-m-7H zLo3f=DZAArzuj=iEgMkzJy}MAjrWRB9orJxI}@5=%GyZ^v7vs zckz3Ns8GK~!Tk5|DKcFO@Lb_~@5~cOhxhLdDqVKknSdYJLPT3+G;Zo56-0+7f#@SU zUDij>E#Hv4sm4GG0cC~A^tJh=PEP_xozi6go`@f&#akrY5Jr5Za-oOVNys4HtKB#& zg}v7#(3#U$D!#3*6rGKAqk<@J!@z|TODB?{W#<$Jv;rj+q^ql@GIHkkYyE$0=>9dL zoLQws5sjyC5|ZC-q*PtzF6A>ZByL!d%(r^rnxug7p(a`B*>CN9|7I(*bs`yyQB)N9 z@9zY!a7fR&H7p7A{B-isnGe4yFf)-8K^VXQFkOhsWvw?~FmZp+;7|oSlPEh+3DE_U zKnmeWKeS29=;-L4fiJW(BHtNPEC`!l5L%>jCXfO(6@<2(a*Y}j93_Ds=|TVim^zYF zTiuy;oi0H@nLuQa8a}4U7jJ3iSGFdz0H^{&R=I5rYauXM_kR+#%9UDK1zEB|ABqR} zkNp<)t-v-F_ZoKSKs7#N5mEl0MhM z0I`#we%qb>5ww`R?O41Q|3As~XM>b)$gt?Fu3`}>)CR;yNa%LGS1e5319&|pQ5uLm zKHH30E;Lzdy{HX?&Vvi7BORI&aOz~YGk>LDrlQ3;(R|I}VR2R8znNN?dYc~MC&lVN z+R0d5{wtMCopirmAZ!|lE(9!3N51LdXEQb7;YX9CFYH+=z2SplE!_2<{+}`ur1pN5o{bebM|Y}f{jL!sBYWJ( zjj5A1)c>ZMP5(3TTp1J8{Em5^{@)HMOvS%TC%58OI2B6^`c}a4hR)ps+sZ2Yg$5cJ z?CB2}gGjucfwOhd8Ol?YVh~v_nBp(Im*5QWafLJKPa*P^x6C?6@tU*`tW8gM^^DFf zDCCEO7f2KyDg4!(+v5bSW>fsK;xx^FFqkNi8hI|b3vjO%GWzW^}KZn z0hU31`gQf|Z1V4-xjtH3=Idw9obCt1h3dIPj_C|wEtFuyOD;a{YIPF?;n4G+^hLPj z>wd)t#Ru$Kt*?t&grcEa8xVEMwGhjT=-dCOzU+~jw5iIGr~TCgi2hwrd95G?@@^eU zWY4RF40`@8oFbFI!6pQ8t_3WH94zL+#?SZBhr(*|@ZWGC>C=}A@?dOa-#l9Wa{7)( zZO~Q2UA197`q-{QbqFA?Q4ieL-EchhH|=dY?0Pl;f{tAMAA`T(t0B z`fxHeqV>NQhm@|ak6I~c_`*r90+*qCalZ0$rJ7%U>7?}kW*=&M6LGECsYK?W&iqji z2?XLYG^8yrMEAN*eJ6}0Qvld^>i)*?S2_NrYNxuW4aAwhg0P#K_n5|i?Y;TWnHBK4 z;(Iatje!~e{7XS$ts%?dyPVoa)NCZlUx!#AYq%Z)WBjKq1=5z=y8y8|F36rj^d z()_m^BDhhd`Rk(O&|hJ(jcnmtB4K~s%FvgXQpB1=r=`&z_&uc z1gBqjYgMZ|t{+LXL(r)v{aR2>iXlc!Eh0~tz+xGIJMgkiM8c^%fc>2st-14&$yXG{ zVtJeUpBKt3nV8_Yp0GSAaDw}vH<-><^Z5Bn9$c6`H5^@^7&G7P|2z)I%bUS|Qv$C^ z_c?wW1YpKeMFj|G-kr$D@1}$t&gTD{kc0JT;<*&pG?3z{!(xxjmDB&9c2WLJbH1rs z0!|;$D{!lwy$4) = tag stripped of leading 'v' (e.g. "1.08.0") # = macos | windows | linux # = x64 | arm64 -# = dmg | msi | zip | deb | rpm | AppImage | tar.gz +# = dmg | msi | zip | deb | rpm | AppImage | flatpak | tar.gz # # Consumed by: .github/workflows/create-release.yml, .github/workflows/bump-*.yml, # BUILDING.md, local release runbooks. @@ -26,6 +26,7 @@ # amethyst-desktop-1.08.0-linux-x64.deb # amethyst-desktop-1.08.0-linux-x64.rpm # amethyst-desktop-1.08.0-linux-x64.AppImage +# amethyst-desktop-1.08.0-linux-x64.flatpak # amethyst-desktop-1.08.0-linux-x64.tar.gz # amy-1.08.0-macos-arm64.tar.gz # amy-1.08.0-macos-x64.tar.gz @@ -74,6 +75,7 @@ collect_assets() { desktopApp/build/compose/binaries/main-release/deb/*.deb \ desktopApp/build/compose/binaries/main-release/rpm/*.rpm \ desktopApp/build/appimage/*.AppImage \ + desktopApp/build/flatpak/*.flatpak \ desktopApp/build/portable/*.tar.gz \ desktopApp/build/portable/*.zip \ ; do From 500de62841a3cc51516ddadbc4a024c10c1fdc7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:15:56 +0000 Subject: [PATCH 156/206] fix(ui): explain pending OTS on tap, render the stamp glyph and pill icons larger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping the pending OTS pill now shows a toast explaining that the attestation is waiting to be stamped into the Bitcoin blockchain (new ots_info_pending_description). The OpenTimestamps glyph gets a near-full-em content box — its fine outline read much lighter than Material's solid shapes at the standard 80..880 bounds — and HeaderPill icons go from 11dp to 13dp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/elements/DisplayOts.kt | 9 +++++- amethyst/src/main/res/values/strings.xml | 1 + .../font/material_symbols_outlined.ttf | Bin 469812 -> 469856 bytes .../amethyst/commons/ui/note/HeaderPill.kt | 2 +- .../add_custom_glyphs.py | 29 +++++++++--------- 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt index 43ffee19e3..22c3720962 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayOts.kt @@ -75,11 +75,18 @@ fun DisplayOts( }, whenPending = { // The stamp icon plus an ellipsis: attestation requested, not yet - // anchored. The content description carries the words. + // anchored. The content description carries the words; tapping + // explains what is being waited on. HeaderPill( symbol = MaterialSymbols.OpenTimestamps, text = "…", contentDescription = stringRes(R.string.timestamp_pending_short), + onClick = { + accountViewModel.toastManager.toast( + R.string.ots_info_title, + R.string.ots_info_pending_description, + ) + }, ) }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9005e9f7e7..a9ed842bdd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3021,6 +3021,7 @@ Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. + A timestamp proof for this post has been requested and is waiting to be stamped in the Bitcoin blockchain. It usually completes within a few hours. Edit Article Edit Post diff --git a/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commons/src/commonMain/composeResources/font/material_symbols_outlined.ttf index d75ba235aab931c0844fa28d9529614b88afa8b8..aed61e63f444fc798feb320f8b9ba482543dd9d4 100644 GIT binary patch delta 2369 zcmZ8jU5F%C6|VdLQ+01uSJzMXbni@0_ioSb>?AwWKQj<_+yo-@Xh${Z*|({}lA&m#-XLd+2AMz7IO$(f1yG{EK@(dGc$B z7b72i@5f=uw9G)M(6GgwjH;T$% zLDhNRWxoLuy?OA}Ui1FOJAXR7mz*QVuYZ$VVPxt0+x&GyUb_Bk z|DRuVm!g9GkUdW|eT+U!|I9|6-s?u+df)r zmgZ%%P)`-2$Y3*_yf0Q;Lp21B1PB)_C)3SpY3b|{vZ^%9+96;V6`)w&(0kg2A?^D$vp4k6t^u0Gq~OG$gqal5i%Zj-6GK z5N?^aa!K-bORGe3W0l{`Bo!Mj6{OSlHlc*5s(q$j@rppqMpb>P4N<1!s=m9$DNC)( z+ni@^wa)Uf5z=~hyj^EKt2to{ZR-Iegbiw|S;)ST=XG^b61H5`jEK`4>y;HSSSosF z^DN}chAX>A>HIP0I6qS&$9kSv!Q(h}&Ir>1S79g>DlUDhxT1NRdPY)z(mTR@P-D(~ zwOGyr4Pj{xGsF7%33FR-6Dt_eL(VyM9J`~4s1qG)R6y(0B$`>+ri_;!SIHqZHA_sa zBxQ`El5n6#fHvWd216G6t`?RN%4NK=@Yox-RQg1dkVQ+Qw1ro}k_HX5UC}ez-LcG> z5~{6DuW%4ZJO6U2n*_>uyqO`Yx4W~m+fxK-2#d+-`ycqoMxzj?5@ey=a>alAnF|XV z8xx#~aOnQl`HSbbo{K5VT4@}|f~*c`nvl3NBus&_p`8EskF8voq)3gjtt8d5&5g89 zTFyn1q@_!ePCqe1n>bIMN?R`yqw?MCbv?-ov5N~p~4UmaOWiOL^btr`+~ z+~r#F?ik}rh*>{19)H!s-Wz|nRE|#V|Zk+EF9mbtsO>FmA>M(AYR~##GdS!6h zD@u*c7sf899e_;4G|ved>U|Zfre26qPo^Sz$(is!F-7lR%P!%DSIXlrum;wK-O7o4J& z01tRfcXtU6e95T5S>-8?43&a>czLM_{ZSkNWirB;vxZSllt=#&PMIV|lUZdHjzM4P0)_v}^?zh|d8^4bt@~sCxde0-@EWbX9qFWc9IkDb)GK!)X_rI|Jxyb$b z*(iGRfoIOIFTM-oUoP%nJ;ZMoMQ{8Xzxv|iU;6CT?H6uEQSz%Odf}mq2zsjfr-LZ^ z+mn$z^OH*l`xo|J`rd7zbm2e01cUmc+Q9iEIB#CMeC_)ATOYdz=YK{K{m0{1KC}O$ zPk-_CD6%hx^X2{PhvaEiN6`n!bp#xLV*m2NCm#7Kz;Eg(V(#$D)oVu|?EgKAepg0O z^|Oe;i{3u>>|@{m;bZC9+nTcHVToS9_W9N3_sf4he)Jx4j3-lZGx2ys$siZ28e)H1KqCc7Mg*B~rtFms_?d9q8H0|&H98d2CdSMrwfK<1e$dIyZ5Q1sRZXB4;rh9WBEz#ex1n?!D$+#)@ZMV8y z1dfg09wPU~lV(4iO#_?$ApFH@dUEGx8ki3Hqagw&WAK~8eHz^U&wW0@+M9z>xa)@i z}*k9GX5=s57jPZ`MG)b5ky@A>!z#FP~b>z;`?c<@R{euFJBGrXzG) zfJcA;V8{e3%EziF<3JbdES;UD!-n%{cbnH?UX_xekGS3oBoW>p4hEnxLIq%mW(Z`% zN1+rfJ46@4U?=nX=*lbA$#HwujB9V%j8JfJ3>=os!x2_>^y61fEC%5gpFy9Tl3WnZ zxRkLqQV_xnQ(Dx5WvNuLV9H3hoe0WD)^k#}oUw!uSq!#Uy$ri#ttdA$Y=z$_%M%GF zgnDDsoN@2ERg!IJ!Hu))t;*L%Gr|^1?mLR zDl}W9@X@rBZ3G6~ur;a$MJa2HGim`&aIIo2)UA|PRx!;9^OWb#dzW+S8GM+Ebu0yC zjEcsc!VAHcvI0zvuN2@2WzwuVwAm18ts;v6foUpfkf6n4DqO4rW83Oz)evBYa@V4$ zrKMD-<)jca%RZGW@FYxXM+wtJc4oWh=AHnA(3lM0{oXS}VsNl34ko6BWJeF*Ph;H# z3M<6Uy^9Cmch}P~rD-=t6KS_gy(OmVAunT!a`x}H(v)l4@>-gta3bSc7%L59CQEW_ zZlQ3_C08s(2SZUrchat)MyDvyoXpa@azUxpK5Jn_Osr(R zmR&{r33I*|BNQs2lp42TG4_$M3&yKF)&}t+In!G+6r1ecUeS<9@5W=`?@B|X_2%Zr zI!dEO*&}>AFFL*gZ+#-SuF6Z`IoFF#d&N8G!MbA>FGy^ycDFnPrA?}fUKJh{MAl~ZeDmtmTCeRG$?adS(z$ppg%%6G|;u$Wp@mU^TjB;0RSN#y|yI0E?-_2^uqNkNZ|YQr1c$m~3mWnmz+WC9&p! zBUJi&`7$7U-g3F5!X!{Ycosy+xQ%+jKF4by-{CE8Iz8INYRWoB84Nf|$0XhP_=E31e|OHPlaw5Q+iXTnj+qM%6*C{2EIFBv(vbHo z1TVFR*e(7|G3CNFO=3fluh0^v*s$h>D7 (glyph name, traced SVG). Keep codepoints at the very end of -# the BMP private-use area (U+F8xx tail) to stay clear of the range Material -# Symbols actually allocates. +# codepoint -> (glyph name, traced SVG, content-box fractions). Keep +# codepoints at the very end of the BMP private-use area (U+F8xx tail) to +# stay clear of the range Material Symbols actually allocates. +# +# Material's own solid shapes draw inside 80..880 of a 960 upm em; a glyph +# with fine outlines (like the OpenTimestamps stamp) reads much lighter at +# the same bounds, so it gets a near-full-em box to appear the same size. CUSTOM = { - 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg")), + 0xF8F0: ("opentimestamps", os.path.join(HERE, "custom", "opentimestamps.svg"), 20 / 960, 940 / 960), } -# Fit content into the same square Material Symbols draw in (its check_circle -# glyph spans 80..880 in a 960 upm font). Scaled to the font's real upm. -BOX_MIN_FRAC = 80 / 960 -BOX_MAX_FRAC = 880 / 960 - def svg_path_data(svg_file): with open(svg_file, "r", encoding="utf-8") as f: @@ -54,15 +53,15 @@ def draw_svg(paths, pen): parse_path(d, pen) -def build_glyph(paths, upm): +def build_glyph(paths, upm, box_min_frac, box_max_frac): # First pass: raw outline bounds. bounds = BoundsPen(None) draw_svg(paths, bounds) x_min, y_min, x_max, y_max = bounds.bounds - # Uniform scale into the Material content box, centered on both axes. - box_min = BOX_MIN_FRAC * upm - box_max = BOX_MAX_FRAC * upm + # Uniform scale into the content box, centered on both axes. + box_min = box_min_frac * upm + box_max = box_max_frac * upm box = box_max - box_min scale = box / max(x_max - x_min, y_max - y_min) tx = box_min + (box - (x_max - x_min) * scale) / 2 - x_min * scale @@ -79,8 +78,8 @@ def main(font_path): upm = font["head"].unitsPerEm order = font.getGlyphOrder() - for codepoint, (name, svg_file) in sorted(CUSTOM.items()): - glyph = build_glyph(svg_path_data(svg_file), upm) + for codepoint, (name, svg_file, box_min_frac, box_max_frac) in sorted(CUSTOM.items()): + glyph = build_glyph(svg_path_data(svg_file), upm, box_min_frac, box_max_frac) if name not in order: order.append(name) From 079822ed9d3fb0f5fe281c94ad66f3923ac0b064 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:30:40 +0000 Subject: [PATCH 157/206] fix: NPE in AntiSpamFilter.isSpam when the first duplicate was evicted from the LRU cache The spam check can fire via the spamMessages record alone, after the original event id/address has been evicted from the 2000-entry recentEventIds/recentAddressables LruCache. In that case existingEvent / existingAddress is null and building the njump link threw a NullPointerException, aborting event consumption in LocalCache. Fall back to the current event's link when the original is no longer cached, and use setOfNotNull in logOffender so a null cache entry can't sneak into the Spammer sets through the platform-type hole. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGiCujAHWMbmGT89X77S4K --- .../vitorpamplona/amethyst/model/AntiSpamFilter.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt index 791d69dff1..dbecbb436f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AntiSpamFilter.kt @@ -82,10 +82,12 @@ class AntiSpamFilter { (recentAddressables[hash] != null && recentAddressables[hash] != address) || (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventAddresses.contains(address)) ) { + // may be null if the first duplicate was evicted from the LRU cache + // while the spammer record still matches this hash. val existingAddress = recentAddressables[hash] - val link1 = njumpLink(NAddress.create(existingAddress.kind, existingAddress.pubKeyHex, existingAddress.dTag, relay)) val link2 = njumpLink(NAddress.create(event.kind, event.pubKey, event.dTag(), relay)) + val link1 = existingAddress?.let { njumpLink(NAddress.create(it.kind, it.pubKeyHex, it.dTag, relay)) } ?: link2 Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } @@ -111,8 +113,10 @@ class AntiSpamFilter { (existingEvent != null && existingEvent != event.id) || (spamMessages[hash] != null && !spamMessages[hash].duplicatedEventIds.contains(event.id)) ) { - val link1 = njumpLink(NEvent.create(existingEvent, null, null, relay)) val link2 = njumpLink(NEvent.create(event.id, null, null, relay)) + // existingEvent may be null if the first duplicate was evicted from the + // LRU cache while the spammer record still matches this hash. + val link1 = existingEvent?.let { njumpLink(NEvent.create(it, null, null, relay)) } ?: link2 Log.w("Duplicated/SPAM") { "${relay?.url} $link1 $link2" } @@ -149,12 +153,12 @@ class AntiSpamFilter { Spammer( pubkeyHex = event.pubKey, duplicatedEventIds = setOf(), - duplicatedEventAddresses = setOf(recentAddressables[hashCode], event.address()), + duplicatedEventAddresses = setOfNotNull(recentAddressables[hashCode], event.address()), ) } else { Spammer( pubkeyHex = event.pubKey, - duplicatedEventIds = setOf(recentEventIds[hashCode], event.id), + duplicatedEventIds = setOfNotNull(recentEventIds[hashCode], event.id), duplicatedEventAddresses = setOf(), ) } From e684cb02a3d2e0ff3bba5860f7cf765c8232a687 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:31:23 +0000 Subject: [PATCH 158/206] feat(ui): tap explainers for the PoW, private-rumor and expiration markers Following the OTS pill's pattern, tapping now explains what the marker means: PoW describes the mining difficulty as an anti-spam stamp (pow_info_description), the private-rumor lock explains the unsigned private delivery and its deniability (private_rumor_info_*), and the expiration pill shows the request to delete plus the exact expiry date/time (expiration_info_description). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AgEpNtwnetPhETXQSdq4b5 --- .../amethyst/ui/note/NoteCompose.kt | 35 +++++++++++++++---- .../amethyst/ui/note/elements/DisplayPoW.kt | 20 ++++++++--- .../loggedIn/chats/feed/ChatMessageCompose.kt | 2 +- .../loggedIn/threadview/ThreadFeedView.kt | 4 +-- amethyst/src/main/res/values/strings.xml | 4 +++ 5 files changed, 51 insertions(+), 14 deletions(-) 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 56c40f13d6..b1810aac12 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 @@ -355,6 +355,8 @@ import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date @Composable fun NoteCompose( @@ -1761,7 +1763,10 @@ fun SecondUserInfoRow( } @Composable -fun DisplayExpiration(expirationDate: Long) { +fun DisplayExpiration( + expirationDate: Long, + accountViewModel: AccountViewModel, +) { val context = LocalContext.current // Beyond a year timeAheadNoDot switches to a full date, which is too wide // for the pill: clamp it. @@ -1775,6 +1780,13 @@ fun DisplayExpiration(expirationDate: Long) { symbol = MaterialSymbols.Timer, text = text, contentDescription = stringRes(R.string.expiration_date_label), + onClick = { + accountViewModel.toastManager.toast( + R.string.expiration_date_label, + R.string.expiration_info_description, + SimpleDateFormat.getDateTimeInstance().format(Date(expirationDate * 1000)), + ) + }, ) } @@ -1886,13 +1898,13 @@ fun FirstUserInfoRow( val pow = remember(noteEvent) { noteEvent?.strongPoWOrNull() } if (pow != null) { - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } - Expiration(baseNote) + Expiration(baseNote, accountViewModel) if (baseNote.isPrivateRumor()) { - PrivateRumorMark() + PrivateRumorMark(accountViewModel) } if (isPinned) { @@ -1924,10 +1936,16 @@ fun PinnedMark() { } @Composable -fun PrivateRumorMark() { +fun PrivateRumorMark(accountViewModel: AccountViewModel) { QuietMark( symbol = MaterialSymbols.Lock, contentDescription = stringRes(R.string.private_rumor_mark), + onClick = { + accountViewModel.toastManager.toast( + R.string.private_rumor_info_title, + R.string.private_rumor_info_description, + ) + }, ) } @@ -1968,12 +1986,15 @@ fun JumpToParentReplyButton( } @Composable -fun Expiration(note: Note) { +fun Expiration( + note: Note, + accountViewModel: AccountViewModel, +) { val event = note.event if (event != null) { val expires = remember(event) { event.expiration() } if (expires != null) { - DisplayExpiration(expires) + DisplayExpiration(expires, accountViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt index e6fc7b772e..681975f006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayPoW.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @@ -32,20 +34,30 @@ import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn @Preview fun DisplayPoWPreview() { ThemeComparisonColumn( - toPreview = { DisplayPoW(pow = 24) }, + toPreview = { DisplayPoW(pow = 24, accountViewModel = mockAccountViewModel()) }, ) } /** * Compact pill showing the proof of work a received note carries: a gear plus - * the difficulty in leading zero bits. Sits inline in note headers, so it - * stays at text height. + * the difficulty in leading zero bits. Tapping it explains what the number + * means. Sits inline in note headers, so it stays at text height. */ @Composable -fun DisplayPoW(pow: Int) { +fun DisplayPoW( + pow: Int, + accountViewModel: AccountViewModel, +) { HeaderPill( symbol = MaterialSymbols.Manufacturing, text = pow.toString(), contentDescription = stringRes(R.string.pow_settings_title), + onClick = { + accountViewModel.toastManager.toast( + R.string.pow_settings_title, + R.string.pow_info_description, + pow.toString(), + ) + }, ) } 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..f6b09bb796 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 @@ -289,7 +289,7 @@ fun NormalChatNote( val pow = remember(note) { note.event?.strongPoWOrNull() } if (pow != null) { Spacer(StdHorzSpacer) - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } } else { DisplayDraftChat() 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 d3a2b8a1dd..51993a25a9 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 @@ -740,7 +740,7 @@ private fun FullBleedNoteCompose( CheckAndDisplayEditStatus(editState) - Expiration(baseNote) + Expiration(baseNote, accountViewModel) // The dotted TimeAgo carries its own " • " separator and the // options button has slack around its icon: keep the pair unspaced. @@ -781,7 +781,7 @@ private fun FullBleedNoteCompose( val pow = remember(noteEvent) { noteEvent.strongPoWOrNull() } if (pow != null) { - DisplayPoW(pow) + DisplayPoW(pow, accountViewModel) } if (isDraft) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a9ed842bdd..7a864a29f1 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1234,6 +1234,8 @@ is a public bookmark here is a private bookmark here Private — only visible to tagged participants + Private Note + This note was delivered privately and is not signed. Only the tagged participants can see it, and nobody can prove to outsiders who wrote it. Make private: gift-wrap the note to the notified users only Make public Replies to a private note always stay private @@ -2822,6 +2824,7 @@ Add expiration date Remove expiration date Expiration Date + The author asked relays to delete this note after %1$s. It will disappear from feeds once it expires. Post will be hidden by clients after this date (NIP-40) Select expiration date and time Expires in %1$s @@ -3776,6 +3779,7 @@ Added at the end of the message when opening a new post, reply, quote, or article. Leave empty to disable. Your signature Proof of Work + The author spent computing power to mine this note to difficulty %1$s — a proof-of-work stamp that makes spamming expensive. Difficulty Mines a NIP-13 proof of work into your posts before publishing so relays and readers can weigh them against spam. Higher values take exponentially longer to mine. Posts publish in the background once mined. Off From 079417ba606756cd084db813ec8167a362b698a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:32:47 +0000 Subject: [PATCH 159/206] feat: add submission-ready Flathub manifest variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained desktopApp/packaging/flatpak/flathub/ directory whose contents are copied verbatim into the flathub per-app repo at submission time (Flathub has no separate accounts — submission is a GitHub PR): - manifest builds from the published GH Release tarball (pinned to v1.12.6 url + sha256 from the release asset digest) instead of the local type:dir tree CI uses — Flathub build servers must fetch all sources themselves - x-checker-data (json type, is-main-source) on the archive source so flatpak-external-data-checker auto-PRs url/sha256 bumps and metainfo entries on every new GitHub Release - own metainfo copy carrying the permanent history Flathub requires (the CI variant keeps injecting its entry at build time); screenshots remain the documented submission blocker - flathub.json restricting builds to x86_64 (no aarch64 tarball exists and jpackage cannot cross-compile one) Verified locally: built the flathub manifest end-to-end from the real v1.12.6 tarball (sha256 enforced by flatpak-builder), installed it, and booted the actual app inside the sandbox under Xvfb — UI rendered and the embedded Tor daemon spawned. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GYfNxhPZC3WRrn82Dm2Cb4 --- .gitignore | 4 +- desktopApp/packaging/flatpak/README.md | 38 +++++-- ...com.vitorpamplona.amethyst.Desktop.desktop | 9 ++ ...itorpamplona.amethyst.Desktop.metainfo.xml | 52 ++++++++++ .../com.vitorpamplona.amethyst.Desktop.yml | 98 ++++++++++++++++++ .../packaging/flatpak/flathub/flathub.json | 3 + .../com.vitorpamplona.amethyst.Desktop.png | Bin 0 -> 16645 bytes 7 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml create mode 100644 desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml create mode 100644 desktopApp/packaging/flatpak/flathub/flathub.json create mode 100644 desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png diff --git a/.gitignore b/.gitignore index 2616f94e70..23d04ed5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -182,8 +182,8 @@ desktopApp/packaging/appimage/squashfs-root/ # flatpak-builder state/cache from local builds (CI uses --state-dir under desktopApp/build/) .flatpak-builder/ -desktopApp/packaging/flatpak/build-dir/ -desktopApp/packaging/flatpak/repo/ +desktopApp/packaging/flatpak/**/build-dir/ +desktopApp/packaging/flatpak/**/repo/ # Git worktrees .worktrees/ diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md index c732506f48..552fba7e42 100644 --- a/desktopApp/packaging/flatpak/README.md +++ b/desktopApp/packaging/flatpak/README.md @@ -6,10 +6,10 @@ used two ways: 1. **Release CI** (`.github/workflows/create-release.yml`, `linux-portable` leg) builds a single-file bundle from it on every tag and attaches it to the GitHub Release as `amethyst-desktop--linux-x64.flatpak`. -2. **Flathub submission** (future) — the same manifest is the starting - point, but Flathub requires building from downloadable sources instead of - the local `type: dir` tree, so it will need a source rewrite at - submission time. +2. **Flathub submission** — the `flathub/` subdirectory holds a + submission-ready variant that builds from the published GitHub Release + tarball (Flathub's build servers must fetch sources themselves; the + local `type: dir` tree used by CI is not allowed there). ## Files @@ -24,6 +24,12 @@ used two ways: - `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry - `icons/512/com.vitorpamplona.amethyst.Desktop.png` — 512x512 icon (copy of `desktopApp/src/jvmMain/resources/icon.png`) +- `flathub/` — self-contained, copy-ready Flathub submission dir: its own + manifest (archive source pinned to the release tarball URL + sha256, with + `x-checker-data` so Flathub's update bot bumps it), its own metainfo + (carries the permanent `` history Flathub requires), desktop + entry, icon, and `flathub.json` (`only-arches: x86_64` — we publish no + aarch64 tarball, and jpackage can't cross-compile one) ## Local build @@ -70,16 +76,28 @@ from Flathub automatically on the user's machine. ## Submission to Flathub -Follow https://docs.flathub.org/docs/for-app-authors/submission +Follow https://docs.flathub.org/docs/for-app-authors/submission — no +separate Flathub account exists; everything runs through GitHub PRs. + +**Remaining blocker before submitting:** at least one screenshot with a +publicly reachable URL in `flathub/….metainfo.xml` (the Flathub linter +rejects the current empty placeholder). 1. Fork `flathub/flathub` on GitHub. 2. Branch from `new-pr` (NOT `master`). -3. Copy this manifest + AppStream + desktop into a new directory matching - the app id, and rework the `type: dir` source into a - buildable-from-source or downloadable-artifact form per Flathub policy. +3. Copy the contents of `flathub/` verbatim into a new top-level directory + named `com.vitorpamplona.amethyst.Desktop`. 4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop". -5. After merge, a per-app repo is created with write access for ongoing - updates. +5. After merge, Flathub creates a per-app repo + (`flathub/com.vitorpamplona.amethyst.Desktop`) with write access for + ongoing updates. Its `x-checker-data` makes + flatpak-external-data-checker open update PRs there automatically on + each new GitHub Release (bumping url/sha256 and appending the metainfo + `` entry). + +To test the Flathub variant locally, run the same flatpak-builder commands +as above from inside `flathub/` — it downloads the pinned release tarball +instead of using a local Gradle build. ## License metadata diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop new file mode 100644 index 0000000000..cc52ce21e4 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Amethyst Desktop +Comment=Nostr client for desktop +Categories=Network;InstantMessaging; +Exec=amethyst-desktop +Icon=com.vitorpamplona.amethyst.Desktop +Terminal=false +StartupWMClass=Amethyst diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml new file mode 100644 index 0000000000..a203d09e68 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.metainfo.xml @@ -0,0 +1,52 @@ + + + com.vitorpamplona.amethyst.Desktop + CC0-1.0 + MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 + Amethyst Desktop +

Nostr client for desktop + + +

+ Amethyst Desktop is a desktop client for the Nostr protocol. + Browse feeds, post notes, send zaps (Lightning Network), and + participate in NIP-53 live audio rooms. +

+
+ + com.vitorpamplona.amethyst.Desktop.desktop + + https://github.com/vitorpamplona/amethyst + https://github.com/vitorpamplona/amethyst/issues + https://github.com/vitorpamplona/amethyst + + + Vitor Pamplona + + + + + + Network + InstantMessaging + + + + + + + + + + + diff --git a/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml new file mode 100644 index 0000000000..54faa72fe1 --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/com.vitorpamplona.amethyst.Desktop.yml @@ -0,0 +1,98 @@ +# Flathub submission manifest for Amethyst Desktop. +# +# Unlike ../com.vitorpamplona.amethyst.Desktop.yml (which packages the locally +# built tree for release CI), this variant builds from the published GitHub +# Release tarball — Flathub's build servers must be able to fetch every source +# themselves. This directory is copy-ready: everything in it (manifest, +# metainfo, desktop entry, icon, flathub.json) is exactly what gets committed +# to the flathub per-app repo at submission time. +# +# Update flow after each Amethyst release: bump url + sha256 on the archive +# source and append a entry to the metainfo. The x-checker-data +# block below lets Flathub's flatpak-external-data-checker bot do both +# automatically once the app is published. + +app-id: com.vitorpamplona.amethyst.Desktop +runtime: org.freedesktop.Platform +runtime-version: '24.08' +sdk: org.freedesktop.Sdk +command: amethyst-desktop + +add-extensions: + org.freedesktop.Platform.ffmpeg-full: + directory: lib/ffmpeg + version: '24.08' + add-ld-path: . + autodownload: true + autodelete: false + +cleanup-commands: + - mkdir -p /app/lib/ffmpeg + +finish-args: + - --share=network + - --share=ipc + # Compose Desktop renders through AWT/skiko, which is X11-only on Linux + # (runs under XWayland on Wayland sessions). fallback-x11 + wayland would + # leave the app without a usable display socket on Wayland sessions, so + # grant x11 unconditionally. + - --socket=x11 + - --socket=pulseaudio + - --device=dri + - --filesystem=xdg-download + - --filesystem=xdg-pictures + - --talk-name=org.freedesktop.Notifications + - --talk-name=org.freedesktop.secrets + # GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default). + - --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0 + +# No openjdk module / sdk-extension: the jpackage tree in the release tarball +# already bundles its own trimmed JRE under Amethyst/lib/runtime/. +modules: + - name: amethyst-desktop + buildsystem: simple + build-commands: + # Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst. + # Wrapper at /app/bin/amethyst-desktop forwards args. + - mkdir -p /app/lib/Amethyst + - cp -r ./Amethyst/* /app/lib/Amethyst/ + - install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop + - install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/ + - install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/ + - install -Dm644 icons/512/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/512x512/apps/ + sources: + # jpackage self-contained tree published by release CI. The tarball + # carries a single Amethyst/ top-level dir — keep it (strip-components + # defaults to 1, which would flatten it away). + - type: archive + url: https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amethyst-desktop-1.12.6-linux-x64.tar.gz + sha256: 42250027857b37f184ebca52bf6124ff6524be4bc15fedf819e4acaca8bc9538 + strip-components: 0 + # flatpak-external-data-checker: watches GitHub Releases and opens + # update PRs against the flathub repo (bumps url/sha256 above and, + # via is-main-source, appends the entry to the metainfo). + x-checker-data: + type: json + is-main-source: true + url: https://api.github.com/repos/vitorpamplona/amethyst/releases/latest + version-query: .tag_name | sub("^v"; "") + timestamp-query: .published_at + url-query: '"https://github.com/vitorpamplona/amethyst/releases/download/" + + .tag_name + "/amethyst-desktop-" + (.tag_name | sub("^v"; "")) + + "-linux-x64.tar.gz"' + + - type: script + dest-filename: amethyst-desktop.sh + commands: + - "#!/bin/sh" + - exec /app/lib/Amethyst/bin/Amethyst "$@" + + - type: file + path: com.vitorpamplona.amethyst.Desktop.metainfo.xml + + - type: file + path: com.vitorpamplona.amethyst.Desktop.desktop + + - type: dir + path: icons + dest: icons diff --git a/desktopApp/packaging/flatpak/flathub/flathub.json b/desktopApp/packaging/flatpak/flathub/flathub.json new file mode 100644 index 0000000000..c75b742acf --- /dev/null +++ b/desktopApp/packaging/flatpak/flathub/flathub.json @@ -0,0 +1,3 @@ +{ + "only-arches": ["x86_64"] +} diff --git a/desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png b/desktopApp/packaging/flatpak/flathub/icons/512/com.vitorpamplona.amethyst.Desktop.png new file mode 100644 index 0000000000000000000000000000000000000000..ffe89a16f0378d0e21e8ab3e1c8b2d46b4cf0b52 GIT binary patch literal 16645 zcmd6P^8l^-s7P&WvnP1g4iK7rQ7$P zzFD32ePd>uazb2|dw`8XUC}#Fr*ir19dwzxk~bw~PVnp^7cr2Y)U#S1wHKc zWn-JO>_YI{BMQ2jv2fB?A`#4CS=rCn>B_xEYlDJpme-K^)$i*13b%ZlE!6SmUbqL? zl6UWMzUhm}_5VQ-y~YJFKQzQf20>_&EbviRfgb!#6$YfB0Mi2>dD%~8L#70SpiDF` z_()Ip|MlR6Y|>2kH*Wn2v-h%k6OVfhPQDt>Ts7XGzZA5Z!naxxp1}O8yto9PFsWX* z5;&Yu|3nirC(tw^7+RX>K7J1G!e}_FQ+la@}Kgc*~0>(R&72o#q#_SNY)3 zq7{{ZoKW3k79_@${^@qyTRjn9s`$uyvTES`-k-PAzcYEaVispf_&(d%-PbVaEjv6N zgIeu)ZxBIE|GIW>7elpexpmuc#mO$F#Ls!H5E^rI?U~vC54%=B!ECcf(!Z)T?LX!^ zx?bGGw*I{e<0S}T*Cr+OX=6$cnKu+zGrT5LtB*_ksJoj-3}I#3hlKRnLuQ-&!;-F{ zJ`$@DXVdp{Gp zAnFOcx|sPreI$()UDf#F^fXNrcqfr;tbsj67+5N@C0J4kAZs{E1V?k4y*+&!R9e+zyoK&k`_GU zvo=|0z65?y06!R;q;LfK^fO2@G$~vIE4}*rYBbEj#RbkoPQ0W@szbW6FhzhA&QL+* zIY-s!V#LSu2BguyKE$jY>d;0;X~Tw?$skf|+&B3fEIM!af_Yh}po7)65TV(b+~X|I zOl>9<99@$7HE<)nH%k%PQGzKnr%a~s<5M2ecb0jm?ne^n6lC?Fe&Jq!OUZkAoq)QD z`)JBflf{H=PiW^382?&@`{MirJAy)2k$@fru12 zX%+{V_siU|@T{3fNf>!iIdQB{uErBdnK{7=5w1Gpsnyb17JeRs z6H>qL3mHEE87_M0E`|q=+#kWv0NGMMgrnE0;D`It6hMUUUXVa)_fj3}{-B_d4l~WO zO0D0>*c^ev*-@X69(;LtI{NQAY4o7DS&I#E*c* z`Yh}gE-V=50tT#fA*zD?%!rYA6o?J%UMe&m`hvhv?y? zyd?2BmQh40kU8N$vdm9W%%djW4nVi(=t3Zh<>UV*Cu3gGz_wqI^na`R?G=NmP)5>Z z-o2p1NFCOb4c$QakP~5E3+1GHf2yTrR2HF;=Oq0tJgYuH&*aWyNqMinFI$l;j{*;S z_{H0rMd?2047OLv0fVN=lxmQ^QLunD3GGChfEgJEp-@M->Ah5XI0@k#S>|mC=Zf7k zYH5J#tDast+x7q|n~UIa%1t6=+ZKEmK@2@q&4>K%z#c2)2dr$aa?eU3C_JM3iXVIr z7w4u+Q35|)(&@V_JgdM{v)%cjn6^O}hzt(a*~2i1=$fOxMN*#qrYb2PY-Jr-P4bKE zk#t^%5CRKiAiBSeHL*VzfrBAcV2?$FK5VkZ%C0jGH_coFDv#BJm^wNs%F$q!0}8QjRPzVRfTxQDh88M%H2FY4zw&bJ@^b>qWA+}-LWH5; zsTPw_DTFY5s6oEVsRZV0L^?PJEIZ?NUB?;lgIlN7>L^a*AeN;ed31W-_^vMtDk|YN!J6 zkRB^@C}BCJ5?Y{CB5rGvUS`V;=Le*9&FIkqY3JdYw~v(ZG6NjCH<^VWyJpbANxmq< zItt^z2JWCRa7HZW;>lWh>sp=lFMqkZEw)h|a|(6IYY6&RoTAnu?4Ofm6j57`Hv^N7 z>T?o>EN_@fxJ}lbe5!xbS1GjUgW5+8?+9&P3L<#V3Qd-SjN@T*IWDBLe$8im&tSIH zX1c2KSCcv0>^tVCw1=w)jjXJesgcdgC477D(l#!-AjYPeuP$Yi`UQ+||8Du4z*xqV zPP#ON9+yn1?)8i~y9NDCUv}&XjVsmN>!#I*ySs-YlYL$OW9#LUdkwQCzV$QJ)w9(c zvQ2?<8GZo)BMxllRh26nDTix1#KqfNWJ8Q(?4j*ZPJ+#)sN&hq3gskMU1;cM#FIX< zZ%u4d(yP2XS(TL%zPm33wME90Y^t=HSuF!#Mg+X8mY=eEV3Nr}Q22dguWlumi7_p< z*oxd7+Kwk%36o0?o6Tn$cp;{tpUc@$$CCwdqWiZcl3$S|v5NU?KAqR}N3p|CKVhhh zp@~8DB~G7NRJ-tSD5H3Z>p?ejuK3iGx_a+kBiK__Jgw|Nw)$%LsOt4U<44PR)q6Y1 z2xgyyp7X8Fb2JG`{@phW@D>>7=^fFcs@a4TKFEAsriLU_dF0`G(7|U?B2Q*C(5M@} zvk{ATy+z2Z+{juqN2h%kD`2JRPnEWM%+=G=g$B7gB~5l144i8snCPMSV+*^z`}pnj zokZ%OoMPwC_+Yu{rhrEiK9ccCyO;`dOosu>ox44nF3b6O5A%?44b09I%3R7+wM5Hi z;!e39cB>>ngdRP-Tfef~9aL&gJQ@^6{dffPQi64)4DQxGO&i)4NaChP-@b&-Rl_{- z`3l#!7+{x>>f;yGo|zuzm&09TTLei!@q=uVeh!DGYpK@i>*=U&K6&ORYkoJ68YV~0 z?=x0;W!d=WPUz(g2uSy1_%=?w*Q1SbOu`eHxXTpS)f%6O$RzisxFr3o~**e6S}` zot=Jy58gXlaFI$O`i;+@gjV9o?hiw5XH6KvFC_3X+PkoPnl{jFVg0!XoBTRO_TW%o z;>wNqmumFrdDeghc{%rFGy=qW7T>K^<*ynOHxa59wN5;)CmVqQU#?|j_zZs#SKnMU z3v8_GxzAe>;^TyE?Mg3Trcx*wGafkWQ6#Ii+C9Kg*41hVKkRCFAC3Kv55xq*vDZj3 zZ5Pvjo{rRh)3B$^ChCwuVDYuJtDf@g8%oOdwDQ_2Ihc!2^j_g#k{K}0O1YLFSe-&3GSR5%cA=Q#BP7-7Sc=rI zl_k!b@!6hpNz+~V#1+pZd5J>Qd9ASr|Sbhm_QoRTeu6ch$cL zh0z3`_|LtvmRrY94@i@skTD}{>u@+pR18Tht=X9PG=lc2bsUIqeH6MidK(VkK50? zsvqnTSZ>dxHSG0fXDR+2d;NSUL?O5nk;R#b7}=Y7A$FF|-|K#z z%e5{HQpEotuy8yTREPq^2-~tkfiXEcrxKP=3@s%V6X*&`zLxIKS#nRaVL6|V7h0RL zS#gNmhm)~~@N;Ke4IgL@+1jb9I;)Ybn0e5198I9+Fk8-H`gHGG0fI`Qy~4!l(P_x> zaJ=nXQxZcHJV9VcnaY2bPVTm!-(v$}kr6M%xP`aKrb*iVL|~Ej(lDLet_Dk&WUYUE zBqP)tfjj@X@;Et&IPf*gI`e}rRNj7EM4*(O51)Jhf|ApT67rcyx(GMj0}z#B_4n!t zq+;+3Sk8>2PwTVd$3fZpnN{j8Ix6;*$eytg-}MNZ;P%_F4h&6i?EP2KaXQ}0NRpwM ztLOYx?&;+}P1JvI8Wn$LLN;xYqN7rsNIzt>nb@)8c1chm$Uq&}3k7KWP4R5mo=WcH z75@^5;C2OAN7xrFR~CWM1+do69k0tLd~|~v1LsK^?QkZ9IY-^~4kpHtn@`~Ju(3DLbIbAz$-8zplr zYO_(e;G=BFriITR<>enwb@)t@-leXvcy)zrKzj2>4QI(rIsQh#y@}V6I;siYI@6f@ zLe-d}tGPU@#3lcX(i;hDJ0APH*Mx4}^duFSUb*Cpe08^Jy~HwA=AbqRz88SJFvm>B zPTCXj@{O__`P+ND7xak--(M@1Oe6K~?Pqo6iuX2@dUj;bGp(*I#vCVwx|$;JC&#tI zfkz2-XI3eEg5DFHesC3LaE8Nz87{O#XPjmZ%i}z>qH%Hhv{lpMJM#oOV;_95<)ptu z-L`K$F8)R=%!XS^UT~>DqTc-XUGEaDhkn-(cgQs->b-C;2RLF~>CskB4xMV4r&noT z=3YOX4DCF$Ky>m;8shz5mZemhADjqT`mSWWOK{@7t~C>li-lz;7A(Cc&~->rDTw&| zyq6L3&~GQInuw4=s3c;m`jij5k9fw>ayxrG?t@=sEH~%c_@6l4H8Z(@<@it2JF)QF z;vEq#dbC3Ry8DI@W;YBlFNMQS;?KjczQ>LIe75KRj_t8k=+WULm%8tqmO*}t{4Sn! zd<^v!Q{MHJCx0@*+`?1}>{9m5>z@h>vOPSJVb{fVgmj&?1*0@=lSZxTkL_@dDFRO) zHN3Ry=Y?+DMbi)-eTzQq>+C7mE=sAId?8aGaPORt=vwRS)3tskm!RJ3 z^OP+f3y2@t#anU=SkiqZ7-f0QTs(L2CGhgSL9YSE@}9x%8^Z^+tE^Q|D(j1xE7z&{ z6RyjOBhGAwlhCzY5dGuoW+50eMhGU*joYb?K72Cv-L&_ORMv>hl3v!iJG*JdlD1|} z{mo<^Q6zNVwq=_eGR$O@7dtq`5A94RpQ|IIi&E=a$xPLoyx`uIyT89IuN#CO7xB1L zDM&F3-))cvGli1Sv1_$))PGv~9gWu-;1Cm3TlQ{zD)nEjB#OV7?ZfPK&nqD?Q7d{h zBJJh^KB_nBz3(}kqEAoyfg_NXhPvEqvLJFUuf}SN!(Tl)QCD}<-2DT#HE7oXwz7W* zR`4+|ZFMhzfA_R%xiXMRbe9RfV5bAiW6Y>jh_JH2f_@{ zy`-E2eYnrwSHzmrCHtk7^yKBXfnwUzqwfA~SV8unH?fQ3^k^TaDuk*oULB0c^0b-8 z=RGXFyHjga!u`r5BA64BWwy{x1>jQfQ6@AL$Gm((YtR|lN<4NO^jvJ_SsF(m(elTB z6GD2U+4h$eN>_>&j6WLjoj(R#$2(XzAINpaL;{^^PWPIP%+FGsd(j=J8;YHp_d?+N zL*i~9_J+`&>;_57-U4z2C%J106=GUHuJVLD#B1)ANu^COCVEe?FJVobXLHo=?K(MD z9!og5EDSnl)U;C`gp=q}6#q2oc4`0C{7Fra4<&$5N;lgI=(aTP%&nYg(vUjZOR=^n z8r~h9%RM(UC-L5uVjX|*+Z)&#>Q>6HZ}ZBnF^S+17#&bv>%6B)L?K@_6kZp!yt?$_ zm+LrD*Jb@r^)J`zpb_IbxGHpD$E{)Xgc_9v4ZYa1Vd3&wIH7GX?h9I&j?M{qJr`8? z!|n>-hIIz9XC|olha`o~Si1%vbo|i!fS|$p9uw8bcfuaCoQ4ef*Xx?J(_@?B)xdb@67ZNv4Y2Qre#gy5o<5z8k;80pHH_XeJE} zguYkJ4O2yr>B$lA-Dd;_U+=@88OOf9Mz)bQTjb4`h`zP=Cw}httrLH~BV*2~m0~M1 z!ha^P?6+7c6fu%zzQj%Lc6}F;=LQ*+@1xlWTtB4?b}OYha4&Q*T3m-#jJdxCZP@#y z9l^hP(zCdSDzt-9tVIzZX;HsD;oj+Mh%@C84h|CAxP$*vxt&1>%AetjGTXL7hN)~e zQuH0`d2SWOR0;c9vlRxsR-;h&%P=^Jo=C%3c>VBRZ9Ou9FGI3so zZ3-Ek^EKO(J&hNtwDGo^GU2QBkDqS7R>%2}ygcypXw8-U!l@M1ND{^Q>glmxrQ;JIq}1 z&6lwps;T#XC0||A7`PoP8wo2ouMq_jg}ptSRjmc3PzA}9WvKy1kA)u5QTa)5d>>bY z6$E>FJDN}CsDW+kKEhR1X{P(DkmrOS^CJ{-4N|Dp)eW#WvS4PTnvvhYLos7HtM7>( zur=}*Zf+5%eXZ5Od39J9a{PocK|W{kKi*L;ebFbS>g~Mwb@vKaBN(qUCE>FDV(T<} zdGZ#zKfY9NE@FT$0pYG|di{+|buK4@qWpca^PMlY^1y1P!rN-Nkie3~G**hh42eeY z65E(5jT|-=`wnJL8LubHgKtvX)alXusdYZfFEKnyiya@TuROJNK~maemg@gmy-WlBY8G48@eB! za$Pj-JeG45v%N;3?4V6*f@3*CtJqNAIVn!%rwhHC8=4kAs?nGPu2k64Q6u$cQAD7? zwIx9eg9t()oLQfQ0~oJFxcFypUd_&_DmLvP00(Vaui1V*(k_?D@|=rdldbb&y`oP- zmdOM{h8qBWR2Ms>Xo%L|?rmwbU8*L3XU2c+P01PXJ0hnLW8~!jyCG{EIKGaIuT7GH zO&T8omHtTQ(g3TcNXoV>i276~F==@q;gi_Pm=QgkGR(GS&o4qYaoNRC_wXz?$o?85 z(1j_(7_z)9PI}K{_oTvd=D5vGjjeTe9ig zl#S29wK5J?e5Oby0VHp$e#wi-_^vBU!120JPiH5wo(SZ6Kg&@^CLWck$yCbKK15Qr zed~3KP4#hv+u<33F7~Z{B2YeNk*_@plg0!IA--k~2gS|A&F4kyFE<(5kdRr$j<6cb zY2yK;?oskcZsj|H?(y}QKVR7K|?qlA(_qp?7Y)+ zZXh&9a?RK>V|k&F*g7nqP@%NVLKc*n7wFM>-muH7AxA3vqfHuI@<2YQ`~e_$h0F?_ zy4Un1M-!ZgZlFi2*$yW@4si7UVLhIRlfQePQE;?@a2@q3jaFY>z5R8|v$qg+KO$ll z`xn^qT`Z?CH8Ht)Bckr<96p=)`;V<^;8YG#2ahM*VwAA`3+`~}%G0BR#Y{XTk`EQZ z>FWC0rmC>?=5h(rQmy$FF8((2G0Mo}4w7<+mdpyuTRX{(QD?e{&E~j~5Xb_DecArQ zt$KVz>&n`rO@XYBMLbV!;yQ5=T4+C@QfU9rcgKJIS+DZ%JF-Sn(vM!WOe-S{M8q5g zse4t}1cl+^FA1&Ib(6)c0sL_1HjI$ZXf0%-Q*sfkmL%Tjh$7|pSgz{SN1N5{R*X4U zAtyrTvQg)LranMe104;&7Gg$T;RY!0p5~K)Ew_ zdt{lL-Hj!^M*rLO*;1{G?Ei47?}4fFw`kL9;jwb!RzlF)_POQVH|yS()!IJ4wguN8 zHj9~73N9Z|fKTtMhMk)Mb|h=FR$i>VUnke}U~dN}B`G_&+am2x)lr6$_v!Pu)k?}Q zIg2?s4lvm{TAr`W>aOw&VlACAnC;7dFj+3-L)h}2vNfGvTh5zxuI|l{ACi1XVrcNh ziUmfXNXoc`eXCcqqzVdQO}D%U{>aPjCbXu?esITFgyAlJ3u!*;wNnM|##s+Ka2p|v zXq{rw*$-=yPeP}jZgz*_nB^SjN?hc$@(dlDTB!v^T-tYFOaL#Xh39TEVqPjQKBm)n zg3Z+dH7W8(F5NJ=PT>A;<{8!xRx(mD8u;#klwWh1=1oUqAYagxp+`%I#Mj;c5wlgK z*VXB8I}2c;Rt;lf50~OC<>n@?smdL5)W#$&*Ag@(?9$y1KE~-mBi|xpES?t3v4KtG zo!T)1AZY|TRN%xM+D8vN9BVmmYZ~g8O-;k5ekVZ_ePFOuj+3; zMv`Zm1dM+vTEiemEf)LspKD%Gn~=C^;8i=KgHX}JAW=yA`)sO^pL;v&l?KZT*;enb zC0v$g1jU^f*O~)5V$u&bIjpAuCfiKor^xswVfDf&#baH1w1rWqPW*kqmxvj0c=ucW zY*;jG=vDpE(tk9ym7Owj*O&4Lw;ojd6uKhYQTl7 z?uSWb)P4KLx3J`nAvf?7zj3ZipoF$Rj2Du9x>Jl76>>h0&5p|n*MoFH_1blkr_J%>B?fF*|ngjhW$5yo-CoE-47sL2L0sdbv04VW^ zdKLxiLUn5KHZR2jc!cs6MQ|Gz$!vYB{4c2F!B~c;v4pCIp+}) z2-R%&YzWe^aO?0~>)M&Px(3`@oGyegw%>Sm79$A+_gH<69#X)IUt!r6j9=V+mCOS# z>sF3g2S=4*VCil5MjA_ks|USiXX!6@IBbTlcOD4gOpj5{c zm6q=4l%^XFs%&2diS8ZQ9>+YumDg&s`p*w8t9)(g*Mkx@@BtZV-Pb_`B%MxSjgA&M zvwYob$Xy-aIdC!sytGVZ$!cCWpoWpzA_lLS2`j5IP{n6B_at`7QlWElbMZE6Ecka# zG=f^`z&+SRr6dA&m?i@kOXUtW2=ok0RA?@7gOm4UNr5wxd#C4Ybdmc$`2gd~F4>17 zR=7(|kS6i*@{CYaG;ScQXEhGgyLnQ)!=pv++c7mS=juFR#FB;>{vlzPA}0=qaht*D z2rl^}#;tQzI>=~#-Us|k6$CnlhC5f#`g=#4DneRexPeT3F8mS1-rrjgeJ=WSg*!6( zSDC}4#eU0gz|k8pH17*MUxNuK(e2|=tUO}T6%-(^e;65Qo3jkV=_#j0n^mlV+)L!< z3qimB!hJ&3gcdno1;mt4?|#CD znpj^sG>m@9R%tMZm%hT*FfqIdYJLLwuGh!9=4H4n7`Zi0p71vsly^BXpEz_b`x-lV z#0a1Kwh=d{!)BB3GNCr~>*a{!ANrIjKDE$SK{T4FFs>fsit{+)59UuA#U?w)+Trka;Mvo9rn_=X9&!D8qa zox?XEEh4h8Ho*7`C|L2j7XR+6C(1A}-@W|Y;)7?x_2G55e4O`3D-ZmPT&A1Kj+)+A z4K6VYRDV1OOmcodh((~f{l`js44k_`;R2Km-Z^_smV#CZ+LZby@AaE5#q{M^2KnG> zC(pj9B>H}oPdUiW#Ac`bjdyCx1W>Na$!m!XaU| zc-X?fZhG{T6V3O{O|?(-Ose)8|134m(0OFXszTSb^3*LlzFJ&kp}L+)D(|(JIP(2# z=Z&{dI$1IH4W=U z@^Vd#B!dj?%)9Q`s*T?;W9A=zsh)T&oFFN>)OxSn)VL{4L(MvH!Ls+R)7~JrITQTM zv0;tWr&F|hXDX%ML~-pv8A(3$^{u|Wb4|sQYdo8sVv~c#hhJNJChy`^JXRO-E-fD1GRx8Y0>0s#&^f}-!S%5ZTp26O1Z1bA;E#N>?Ju}E0Fs8^IY;uQ+;e#(e7v;s z(Q$}2sX44ZXggPg7|p|JT~XaDIoP;u*8x8LsgC6pS1*{0vG!6ZUwf&}-~~8_SB#hAg6K(7`f~ zyQ-@t1>o~}XKlx4*2HGEL>YwA5P3V}iTi@}f|0mq65zEl_JIu{fN*4g=RTdh}9#fP-rMZVXQEOgJyJ zY9>>l8<-Ri&HVmio?up?r2rLwK1)!$W?I=Pw6sO3D`)FnV9ri)JPv&zLGJ1 z*hyd~m~&8}@!LHFO67-zL}`Q~r_9moNx3yUVqW>GaA&}m{6F)qYa>4kJlN68ET9lV zEzlMA=j;HCij(h!WkBwEc;HMzE3M4DsZ0FGsgvqR3S9mGbB)0#;o%EjNJy3e z`<7SbI()|j`BKI6kFO!kUbW=oi-x9(P1HJqDNJyZMgb+*T}?wJd)_cIsXkd}cW{aq z3vA!X-d-Tz>ynT|{3l|So-SSDWg)69v8c;I)#BAxee2p%{(v+US|iwCU_jr+B){Bk zDCD#qgM>P|k+@U&lI;?fL>5qv>_dL%m z@l5*{d{FBZm*3`UK~cDYj-E;`@Pd&@z(xW_?lP8?fj^#RYNPzCPb4mQ_);EZwz2G) z)PV%G0hI+F9MSO8_iMAeI+#4w#cx?D1&Lghj022ib)V@WwQ?Gi;ysYFW&faT>4%H% zn}({_f%Yj~bMJ{NL%Bv5Bo*>O0g2mmgkZDJ_%R3z26tc#uN1ZG_K(+2;A**q>lh;k zXt)9w=A8kc0P{=lK<9~RmsJas`(oyXRb^Eb&wS1;ef+_IF*?H6dKl{CKEYeGp z%y$DHf>9$ybDC#+Vn1Jk)PCK?r(kA)SRPLe(_=YerY7ZuM|nex&^O~K%p_C5+&>=z zd*72=nI%3KC6Ul~QC^BZ%oyREKSE>$@+K34W&_^NU%{|~Y42Vb^&4Q^w3({$wN1(Z zk=QVDR7GoS!DJ6>4GIn|WLDDAB=_SYNJQv3rGj?u#?OG3kOC=Ba#Fk7Z~s%c7ubiz zoG@UsgNvNn7u!$Ndtzd6(3x?c(C&;0353$Xr8>w=D#)+C(=`$|pny>eHLul1hTUQi z|9QZZ#Kwvx4a3xUqR>_^gdmK~e4Z0`rRw5i`UshTj{x;3TdHsMj<2|lq+ySo?36Lz zoqIVT`l{$R*|)lV%Rln*)0eQ>dR$^Shdo71V!Mhx;o=~qXPP~S;6^CLfsE!Z=7PP5 zRRY&SnyP&VY0~;-ER53LTXK{lOc54~JoY3WgxNHMJcZKiELor^LHYm`%&( z^|_@$yO>P~o9F??zUq~>o)V~SmIQvV4X$?y$Hn;w%>rs8@LT&;dOvC@iem3~0ZNd? za|B<~pi|q}B8R90^JP+#kN6+adf;HB>7G&k;IxB5^wmp}{!Xtg)05GhP=I<(nOvkH zk~_hQKK-t|&bF+r`$A8ixwLYv;9`~{J!nT^P7>vIE0_=<(!n{p6OZPx%79wW#_LaL zFBKSJVbz)V-9oK71{Y_~Gs}liOzcfZtzA&l0IeD+kE#x}lVodEk&&P^4LLX>`S$8K z(BaLV9`8t8EGqFhLRS<+x?=|#o1TLa+nM*@1?Znv-h30(!ND%R`2rT|NS|vPNV@gG z0r@H`6WRuwC0MGF3Sj+G&4arDm-^$3M9Ya%mYq-$#rM7^>i zL9yh~!x#yXCHvDZNsrNSNhbq7gz9-+a10te3PPFSk3e~?-6~xo6UtPLtdF%}8{5!X zm%oZo24hBAG-tzbv4#s({>YctKaxdcToH$%S#v>Qt&D~ja{4{;yRu1O$rV5*oo`6W z$oHniQEzjufwddk;BT{?90ZJ9EEJ_`VXBbbSW>pD12b!{B3#Uzq!CJawX*c^hfz0K zqHY=_W-!9D2EgewHj#f+T^;O7|3q4X6w5{U)ou(%mJSQ+;7`hsJiu5M0WAfm#RIpW z;)24R5;6!?R$okH1TNNgZ^nCb3ln&1buTh7m(dx%_REGZ1wmI)*69nME`V*+eM8cJ zWwbhr6EdE6SyZ-vYy+@^nvL3^6wWP60KmOP(a0ZleIMW`FIs_DYrIohgv1=bVoRR- z%@XLMUXk?Eu6g8kdV%FW9zrD8^nu!e_nr^#PrQaaz#=29zRmwizF;H{h^jaK2|Mcgf?W8&>eFa+MPai6`{%r&T)gi@=p8pwM`eXoT0oB%BUVSgvHFf!hK+p zi@M63nu@Ve2f;-N#sC@mJwU#k5?z%DJle_5vbHe>K(De#vp9kx_JNR$XU|TbBsihp zphv?_mx8ibivjpw%0te{q_r#XtIL{QGY(K*&M=$&*36Tp$9nV0U@b36L==Rq)xRm^ zGO@uqme+{8d|Ouzl77IoYR(r0?{qaFBd0J=s!ClR4c z^fu!x1ZD5^oOD)zgD$T%qJETW`SE~-n9KR5EoYbuBb2ipq$YiO z@Ts?4oFP^xyMx8lzwJjC-vA#I8JSgC&~#m{h6vqp1dSdm83_5CW%Q@b;sFZYb{ zGr0shMC#m6t}Lkgah%Rsc=KF;D8*!6!`u62JPd^CDjBnl!5G!h$7kr#9BxY!1ZrtT zfTr|yr9wSwrSgFb$3HIUle|Zhh@8p3>@&r`v`S#9{XVgrqu8`@LSQhi);!u&AM~GktEDvH zty(4b$V%Z1*sIHH&8Qy&n7|8{(Q$$u!EF~yXhrVQqa6#!b}k6i>|Du*9fW^Ew=mTr z@woztHiotjscF%mn6E*E~84S9dXzg`5(VzvlVToS=kO&6;GopX-D~MzF<-n zSV6f@s!D(8E5H(oSIF(zV1P%$mK$u_4e0AY_DU-6Ua16m^}>WL2a6B_l@ZK04jUy`AE zN^&|cvi4+L4@mSGi7u)5BC*@m)=vbKm1>ln)+xlBB@m>swmiknZiS~MUtv()!H99Y zL2~$jKN4&aKp@?v=lTIQ+EIRgw-5w$P|%1=+3E{YD7OUm{$CQYnLuqUwK&ollyLNx zzygr5itb%Q;*(M!DFslv@u}@B1!0QwXN>%2YB7+B<-KMJm=Oh3n;^zr#>NXtRM^x^ z^Cr5lmSS>o%CZM#H5J=JNhhZYUUQah|6njpIkW?`Z=9PxAuP_m9`^x$`lW;8R*h>B z9M0LjFcUEGNFQ4w{pp6n+-TYEJ@kNOgRCk-|ydUVU1wv^g_N5ic$RSh{^`Kwu zUO#yio-|L&vid#A}%9CIFNjrzCL>7c_lLoV5SgIKq# z>67?S%Vw?9dbR9vQ;v^g6=@zUOSCBdb=NguqgSaEWTo{DuRXgagj7}wzN~;cpa`|&>$S^mCi6qK>)Pu=kw++tGS-4hz8QdbIX&D9&zO8O=icP$zB&yezf&esScG`!M~$5M7Rkq?QG5TL zW(-Xn=)8l%m-t27{n$?mD=QObhb^Xp6yab6OwV*C9Y%s^Bedf#H58E7EER4MO&hFe z+q{M4w$ef8lFd}lJJ+~ZuiCfLcbkQzB@s#ny3n&4a8-dc*5n`s=Zqeb$Q*`Mu};-JCGptsg*v)&^OT;AviMpZW+jGF`1f|0Rz zOw|g)dFp|xq$gv7K$d0ef=h)IU^CdbA#o{fud_TLyl7r(f_Q z+a)Iw2qiX}gLMh;XeF==@It{ZfeRml5+~sx+ULxvdAI;&_DIt3b{dxKxS?=^H%X;Z zm@&{18T}aaq6iUHkT1>LGw{H_Eh~TRcz18yRZs9#B#~)2{(~JDFZ9X9_m^i(%C;F$ z5#xMo8$Nf1a)B`xR5=MR3)m1HHUZ zeI2<`=*-Ta_c2S{X84WiwPt~EU@T4C4yq<&e8HgWjn~&BilJfHl?UZ<^k*3)-Ero? z)xlyZc;v0$r);SxErQD_9bsu12P>uKySjjV2S*G5f#V){+NJ+Vep$x@Wnb5&W~Mc* zI5|7OaTRoowe{4TW&>;>wRk@@zwmAbYyqkG)>f9d5@@077|{`WBe*nw8cq?hgHWS2 z*zX{-i5<2QRA3B&Wk&_HY+ZI$o5u159~%mp9)yEDtMZ8!lp#YnS4IQ9Y8(8lbv6gG ziOgM!!dwQL9nn^4hHk70_h0d7qL59sTbf`=|I}A#TeY!C1Yq}ZBGrdWNHE=627r_?I(&(FAjjKFCCet zY=?{c7-gFpEFtwNC%)#o=FkekU|i=;#ev}xNQu$1{HHqZRN&H1Iuqcu~YNV zoRrl!mU_$9rRJAL?pOER0nN+vJ}1x{8Ro~UO3}jf8t*h$rPVjP@-VDwN6u~X(7!c( z*UG%Pu6^%Cy7kVH7ITMxq+oNS@aye2T?v!xHCsK+JBMpzKE!huEv1oNK8tloD`euD zW-9j}a95H~W18PL{Rosj!T8Yzne0ogBPU&&N8hhDZ6!4fUNNj+f7k54C%lcET=Q9( zKLj1v{Ef15%SU5%Hk+(2F6KU~=C0qSgG$U#24@#P6#C~R%!;ycIM^gb?S0T&-m-7H zLo3f=DZAArzuj=iEgMkzJy}MAjrWRB9orJxI}@5=%GyZ^v7vs zckz3Ns8GK~!Tk5|DKcFO@Lb_~@5~cOhxhLdDqVKknSdYJLPT3+G;Zo56-0+7f#@SU zUDij>E#Hv4sm4GG0cC~A^tJh=PEP_xozi6go`@f&#akrY5Jr5Za-oOVNys4HtKB#& zg}v7#(3#U$D!#3*6rGKAqk<@J!@z|TODB?{W#<$Jv;rj+q^ql@GIHkkYyE$0=>9dL zoLQws5sjyC5|ZC-q*PtzF6A>ZByL!d%(r^rnxug7p(a`B*>CN9|7I(*bs`yyQB)N9 z@9zY!a7fR&H7p7A{B-isnGe4yFf)-8K^VXQFkOhsWvw?~FmZp+;7|oSlPEh+3DE_U zKnmeWKeS29=;-L4fiJW(BHtNPEC`!l5L%>jCXfO(6@<2(a*Y}j93_Ds=|TVim^zYF zTiuy;oi0H@nLuQa8a}4U7jJ3iSGFdz0H^{&R=I5rYauXM_kR+#%9UDK1zEB|ABqR} zkNp<)t-v-F_ZoKSKs7#N5mEl0MhM z0I`#we%qb>5ww`R?O41Q|3As~XM>b)$gt?Fu3`}>)CR;yNa%LGS1e5319&|pQ5uLm zKHH30E;Lzdy{HX?&Vvi7BORI&aOz~YGk>LDrlQ3;(R|I}VR2R8znNN?dYc~MC&lVN z+R0d5{wtMCopirmAZ!|lE(9!3N51LdXEQb7;YX9CFYH+=z2SplE!_2<{+}`ur1pN5o{bebM|Y}f{jL!sBYWJ( zjj5A1)c>ZMP5(3TTp1J8{Em5^{@)HMOvS%TC%58OI2B6^`c}a4hR)ps+sZ2Yg$5cJ z?CB2}gGjucfwOhd8Ol?YVh~v_nBp(Im*5QWafLJKPa*P^x6C?6@tU*`tW8gM^^DFf zDCCEO7f2KyDg4!(+v5bSW>fsK;xx^FFqkNi8hI|b3vjO%GWzW^}KZn z0hU31`gQf|Z1V4-xjtH3=Idw9obCt1h3dIPj_C|wEtFuyOD;a{YIPF?;n4G+^hLPj z>wd)t#Ru$Kt*?t&grcEa8xVEMwGhjT=-dCOzU+~jw5iIGr~TCgi2hwrd95G?@@^eU zWY4RF40`@8oFbFI!6pQ8t_3WH94zL+#?SZBhr(*|@ZWGC>C=}A@?dOa-#l9Wa{7)( zZO~Q2UA197`q-{QbqFA?Q4ieL-EchhH|=dY?0Pl;f{tAMAA`T(t0B z`fxHeqV>NQhm@|ak6I~c_`*r90+*qCalZ0$rJ7%U>7?}kW*=&M6LGECsYK?W&iqji z2?XLYG^8yrMEAN*eJ6}0Qvld^>i)*?S2_NrYNxuW4aAwhg0P#K_n5|i?Y;TWnHBK4 z;(Iatje!~e{7XS$ts%?dyPVoa)NCZlUx!#AYq%Z)WBjKq1=5z=y8y8|F36rj^d z()_m^BDhhd`Rk(O&|hJ(jcnmtB4K~s%FvgXQpB1=r=`&z_&uc z1gBqjYgMZ|t{+LXL(r)v{aR2>iXlc!Eh0~tz+xGIJMgkiM8c^%fc>2st-14&$yXG{ zVtJeUpBKt3nV8_Yp0GSAaDw}vH<-><^Z5Bn9$c6`H5^@^7&G7P|2z)I%bUS|Qv$C^ z_c?wW1YpKeMFj|G-kr$D@1}$t&gTD{kc0JT;<*&pG?3z{!(xxjmDB&9c2WLJbH1rs z0!|;$D{!lwy$4) Date: Tue, 14 Jul 2026 14:33:35 +0000 Subject: [PATCH 160/206] fix: crop drawer banner instead of letterboxing short images The left drawer banner used ContentScale.FillWidth inside a fixed 120dp-tall box, so banners wider than the box's aspect ratio left empty bars above and below the image. ContentScale.Crop fills the full banner height and crops the excess width instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BhQ1FCyoKCD1UeBT5mWy18 --- .../amethyst/ui/navigation/drawer/DrawerContent.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a4ba397054..56136b06f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -237,14 +237,14 @@ fun ProfileContentTemplate( AsyncImage( model = profileBanner, contentDescription = stringRes(id = R.string.profile_image), - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, modifier = bannerModifier, ) } else { AsyncImage( model = R.drawable.profile_banner, contentDescription = stringResource(R.string.profile_banner), - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, modifier = bannerModifier, ) } From 103a3dfc23ede2abec4683b21fcf5953fc1ed000 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:41:43 +0000 Subject: [PATCH 161/206] fix: skip event parse of community approval contents that are not JSON objects Clients in the wild publish kind-4550 approvals whose content is plain text (e.g. braille ASCII art) or a custom non-event JSON wrapper instead of the NIP-72 stringified approved event. containedPost() fed any non-blank content straight into Event.fromJson, producing a JsonParseException and a noisy logcat warning for every such approval. Only attempt the parse when the trimmed content starts with '{', so plain-text contents are skipped silently while genuinely malformed event-looking JSON still logs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EXiy3Ajmr3vdrV83jZsUx6 --- .../approval/CommunityPostApprovalEvent.kt | 4 +- .../CommunityPostApprovalEventTest.kt | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt index 3347b88a9e..9414dfe67c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEvent.kt @@ -70,7 +70,9 @@ class CommunityPostApprovalEvent( fun containedPost(): Event? = try { - content.ifBlank { null }?.let { fromJson(it) } + // Only attempts to parse contents that could be an event json. + // Clients in the wild put all sorts of non-event text in here. + content.trim().takeIf { it.startsWith("{") }?.let { fromJson(it) } } catch (e: Exception) { Log.w( "CommunityPostEvent", diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt new file mode 100644 index 0000000000..488764e280 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip72ModCommunities/approval/CommunityPostApprovalEventTest.kt @@ -0,0 +1,72 @@ +/* + * 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.nip72ModCommunities.approval + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CommunityPostApprovalEventTest { + private fun approvalWith(content: String) = + CommunityPostApprovalEvent( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + tags = arrayOf(arrayOf("a", "34550:${"11".repeat(32)}:community")), + content = content, + sig = "22".repeat(64), + ) + + @Test + fun containedPostParsesEmbeddedEventJson() { + val embedded = + """{"id":"${"33".repeat(32)}","pubkey":"${"44".repeat(32)}","created_at":1700000000,"kind":1,"tags":[],"content":"hello","sig":"${"55".repeat(64)}"}""" + + val post = approvalWith(embedded).containedPost() + + assertEquals("33".repeat(32), post?.id) + assertEquals(1, post?.kind) + assertEquals("hello", post?.content) + } + + @Test + fun containedPostIgnoresBlankContent() { + assertNull(approvalWith("").containedPost()) + assertNull(approvalWith(" ").containedPost()) + } + + /** + * Clients in the wild fill approval contents with plain text (e.g. braille + * ASCII art) instead of the NIP-72 stringified event. These must be skipped + * without even attempting a JSON parse. + */ + @Test + fun containedPostIgnoresNonJsonContent() { + assertNull(approvalWith("⠀⠀⠀⠐⣖⠢⢤⣄⡀⠀").containedPost()) + assertNull(approvalWith("Approved by moderator").containedPost()) + } + + /** Valid JSON that isn't a Nostr event (no pubkey field) must return null. */ + @Test + fun containedPostIgnoresNonEventJson() { + assertNull(approvalWith("""{"approvedEvent":{"kind":1111},"moderation":{"action":"approve"}}""").containedPost()) + } +} From 8a34ae692f8bef101389812da10b53c72c393060 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:04:35 +0000 Subject: [PATCH 162/206] feat: adapt the app shell to large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three layout tiers driven by the window width size class, published once through LocalScreenLayout (ScreenLayout.kt): - Compact (phones): unchanged — bottom bar + modal drawer. - Medium (portrait tablets, unfolded foldables): the bottom bar is replaced by a left NavigationRail built from the same user-configured BottomBarEntry list (customization, pinned favorites and new-item dots carry over); the drawer stays modal behind the rail's avatar button. - Expanded (landscape tablets, desktop windows): the drawer is permanently docked on the left (no ModalNavigationDrawer), and on windows >= 1200dp a docked notification panel renders the notifications card feed on the right, sharing last-read marking with the full screen. The panel hides while the Notifications screen itself is open. Large screens also pin the chrome: DisappearingScaffold stops hiding the top/bottom bars on scroll (and stops toggling the OS status bar), and AppBottomBar renders nothing everywhere. Feed content width is capped at 600dp inside wide center panes: the shell measures the center pane and provides (paneWidth - 600dp) / 2 via LocalFeedSidePadding (commons), which rememberFeedContentPadding merges into every feed's contentPadding — the scroll surface stays full-width so pull-to-refresh and edge scrolling keep working. Panes that manage their own width (Messages two-pane, the notification panel) override it back to 0. Screen sweep: Messages now picks single/two-pane from its actual pane width instead of the window size class; the Home/Messages pagers only attach the drawer edge-swipe when a modal drawer exists; top-bar avatar drawer-openers hide on large screens; FABs keep their bottom spacing without the bar; the status editor in the drawer no longer cancels editing when the drawer is permanent. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 10 +- .../amethyst/ui/layouts/ScreenLayout.kt | 108 ++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 19 +++ .../ui/navigation/bottombars/AppBottomBar.kt | 5 + .../bottombars/AppNavigationRail.kt | 155 ++++++++++++++++++ .../bottombars/FabBottomBarPadding.kt | 9 +- .../ui/navigation/drawer/DrawerContent.kt | 128 ++++++++++----- .../topbars/UserDrawerSearchTopBar.kt | 7 +- .../AccountSwitcherAndLeftDrawerLayout.kt | 140 +++++++++++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 67 ++++---- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +- .../chats/rooms/twopane/MessagesTwoPane.kt | 134 ++++++++------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +- .../loggedIn/napplets/NappletsTopBar.kt | 3 +- .../notifications/NotificationSidePanel.kt | 127 ++++++++++++++ .../commons/ui/layouts/PaddingMerge.kt | 22 ++- 17 files changed, 797 insertions(+), 174 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 4611f09a70..fb56c99551 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -38,6 +38,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" + const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 6f36f0e63f..fd8e8ab850 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -80,10 +80,14 @@ fun DisappearingScaffold( ) { val state = rememberDisappearingBarState() + // Large screens (rail / permanent drawer) pin the chrome: bars never slide away on + // scroll, and the immersive status-bar hiding stays off. + val canHideBars = allowBarHide && !LocalScreenLayout.current.isLargeScreen + // Hold the latest values in state so the NSC's captured lambda stays fresh across // recompositions without rebuilding the NSC itself. val latestIsActive by rememberUpdatedState(isActive) - val latestAllowBarHide by rememberUpdatedState(allowBarHide) + val latestAllowBarHide by rememberUpdatedState(canHideBars) val latestAccountViewModel by rememberUpdatedState(accountViewModel) val connection = @@ -100,7 +104,7 @@ fun DisappearingScaffold( } // Only wire the lifecycle observer + system-bar control when the scaffold actually moves its bars. - if (allowBarHide) { + if (canHideBars) { ResetBarsOnResume(state) ImmersiveStatusBarEffect(state) } @@ -110,7 +114,7 @@ fun DisappearingScaffold( // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text // color falls back to Color.Black and is invisible on the dark theme). val baseModifier = - if (allowBarHide) { + if (canHideBars) { Modifier.imePadding().nestedScroll(connection) } else { Modifier.imePadding() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt new file mode 100644 index 0000000000..ae373a5ed6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -0,0 +1,108 @@ +/* + * 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.layouts + +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.getActivity + +/** How the app shell presents its top-level navigation for the current window size. */ +enum class NavigationStyle { + /** Compact windows (phones): bottom navigation bar + modal drawer. */ + BOTTOM_BAR, + + /** + * Medium windows (portrait tablets, unfolded foldables): a left navigation rail + * replaces the bottom bar; the drawer stays modal behind the rail's avatar button. + */ + NAV_RAIL, + + /** Expanded windows (landscape tablets, desktop windows): the drawer docks permanently on the left. */ + PERMANENT_DRAWER, +} + +/** + * The shell layout decisions for the current window, published once per window size change + * through [LocalScreenLayout] so every screen, bar and panel agrees on the same tier. + */ +@Immutable +data class ScreenLayoutSpec( + val navigationStyle: NavigationStyle, + val showsNotificationPanel: Boolean, +) { + /** + * True on the rail and permanent-drawer tiers. Large screens hide the bottom bar and pin + * the top/bottom chrome (no disappearing bars on scroll). + */ + val isLargeScreen: Boolean get() = navigationStyle != NavigationStyle.BOTTOM_BAR + + companion object { + val Phone = ScreenLayoutSpec(NavigationStyle.BOTTOM_BAR, showsNotificationPanel = false) + } +} + +val LocalScreenLayout = compositionLocalOf { ScreenLayoutSpec.Phone } + +/** + * Minimum window width for the docked notification panel: the permanent drawer + * ([PermanentDrawerWidth]) + a readable center pane + the panel ([NotificationPanelWidth]) + * only coexist comfortably from a landscape-tablet-sized window up. + */ +private const val NOTIFICATION_PANEL_MIN_WINDOW_DP = 1200 + +val PermanentDrawerWidth = 300.dp + +val NotificationPanelWidth = 360.dp + +/** + * Maximum width of a feed's content column inside a wide center pane. Wider than this, + * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; + * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + */ +val FeedContentMaxWidth = 600.dp + +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@Composable +fun rememberScreenLayoutSpec(): ScreenLayoutSpec { + val widthSizeClass = calculateWindowSizeClass(getActivity()).widthSizeClass + val windowWidthDp = LocalConfiguration.current.screenWidthDp + return remember(widthSizeClass, windowWidthDp) { + val style = + when (widthSizeClass) { + WindowWidthSizeClass.Expanded -> NavigationStyle.PERMANENT_DRAWER + WindowWidthSizeClass.Medium -> NavigationStyle.NAV_RAIL + else -> NavigationStyle.BOTTOM_BAR + } + ScreenLayoutSpec( + navigationStyle = style, + showsNotificationPanel = + style == NavigationStyle.PERMANENT_DRAWER && + windowWidthDp >= NOTIFICATION_PANEL_MIN_WINDOW_DP, + ) + } +} 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 d4866a71b0..0e25c9e778 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 @@ -29,6 +29,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -59,6 +60,8 @@ import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -282,6 +285,22 @@ fun AppNavigation( ) { val nav = rememberNav() + // One layout decision per window size for the whole shell: bottom bar vs rail vs + // permanent drawer, plus the docked notification panel. Every screen, bar and panel + // below reads the same spec through LocalScreenLayout. + val screenLayout = rememberScreenLayoutSpec() + + CompositionLocalProvider(LocalScreenLayout provides screenLayout) { + AppNavigationLayers(accountViewModel, accountSessionManager, nav) + } +} + +@Composable +private fun AppNavigationLayers( + accountViewModel: AccountViewModel, + accountSessionManager: AccountSessionManager, + nav: Nav, +) { AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { Box(Modifier.fillMaxSize()) { BuildNavigation(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index 145f145807..a635f485ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -73,6 +74,10 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Large screens replace the bottom bar with the navigation rail (Medium) or the + // permanently docked drawer (Expanded). + if (LocalScreenLayout.current.isLargeScreen) return + // Hide the bar on entries that aren't a tab root (drawer or in-app // pushes). Mirrors the back-arrow rule in canPop(). if (nav.canPop()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt new file mode 100644 index 0000000000..d90eb99281 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -0,0 +1,155 @@ +/* + * 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.navigation.bottombars + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +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.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry +import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.ui.navigation.navs.Nav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments +import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size25Modifier +import com.vitorpamplona.amethyst.ui.theme.Size27Modifier +import com.vitorpamplona.amethyst.ui.theme.onSurface65 + +/** + * Medium-width windows: a left rail that carries the same user-configured destinations as the + * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the + * user's customization and new-item dots carry over. The header avatar opens the modal drawer, + * mirroring the avatar button in the phone top bars. + */ +@Composable +fun AppNavigationRail( + nav: Nav, + accountViewModel: AccountViewModel, +) { + val items by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() + val favoritesById = remember(favorites) { favorites.associateBy { it.id } } + + // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + NavigationRail( + containerColor = MaterialTheme.colorScheme.background, + header = { + // Same affordance as the phone top bars: the account avatar opens the drawer. + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + }, + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items.forEach { entry -> + when (entry) { + is BottomBarEntry.BuiltIn -> { + val def = NavBarCatalog[entry.item] ?: return@forEach + val destination = remember(def, accountViewModel) { def.resolveRoute(accountViewModel) } + val selected = currentDestination?.hasRoute(destination::class) == true + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + Icon( + symbol = def.icon, + contentDescription = stringRes(def.labelRes), + modifier = Size25Modifier, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + ) + AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + } + }, + ) + } + + is BottomBarEntry.Favorite -> { + val fav = favoritesById[entry.favoriteId] ?: return@forEach + val destination = + when (fav) { + is FavoriteApp.WebApp -> Route.WebApp(fav.url) + is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) + } + // Favorites carry arguments (url / coordinate), so class matching alone + // would light up every pinned app of the same kind; compare the full route. + val selected = + remember(navBackStackEntry, destination) { + when (destination) { + is Route.WebApp -> getRouteWithArguments(Route.WebApp::class, nav.controller) == destination + is Route.NostrApp -> getRouteWithArguments(Route.NostrApp::class, nav.controller) == destination + else -> false + } + } + val iconModel = + when (fav) { + is FavoriteApp.WebApp -> + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + NavigationRailItem( + selected = selected, + onClick = { if (!selected) nav.navBottomBar(destination) }, + icon = { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt index 18d199d594..c7fc2c4c5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/FabBottomBarPadding.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav /** @@ -39,7 +40,13 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav val FABPaddingFromBottom = 30.dp @Composable -fun Modifier.fabBottomBarPadding(nav: INav): Modifier = if (nav.canPop()) padding(bottom = FABPaddingFromBottom) else this +fun Modifier.fabBottomBarPadding(nav: INav): Modifier = + if (nav.canPop() || LocalScreenLayout.current.isLargeScreen) { + // canPop entries hide the bar on phones; large screens never render it at all. + padding(bottom = FABPaddingFromBottom) + } else { + this + } /** * Convenience wrapper that places [content] in a [Box] with [fabBottomBarPadding] applied. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index a4ba397054..2c2ed264f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -43,6 +43,7 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -56,6 +57,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -102,6 +104,9 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems @@ -147,59 +152,93 @@ fun DrawerContent( openSheet: () -> Unit, accountViewModel: AccountViewModel, ) { - val onClickUser = { - nav.nav(routeFor(accountViewModel.userProfile())) - nav.closeDrawer() - } - ModalDrawerSheet( windowInsets = WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), drawerContainerColor = MaterialTheme.colorScheme.background, drawerTonalElevation = 0.dp, + ) { + DrawerContentBody(nav, openSheet, accountViewModel) + } +} + +/** + * Expanded windows: the same drawer content, permanently docked on the left of the shell + * instead of sliding in as a modal sheet. + */ +@Composable +fun PermanentDrawerContent( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + Surface( + modifier = Modifier.width(PermanentDrawerWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, ) { Column( - Modifier - .fillMaxHeight() - .verticalScroll(rememberScrollState()), + Modifier.windowInsetsPadding( + WindowInsets.systemBars.only(WindowInsetsSides.Bottom + WindowInsetsSides.Start), + ), ) { - ProfileContent( - baseAccountUser = accountViewModel.account.userProfile(), - modifier = profileContentHeaderModifier, - accountViewModel, - onClickUser, - ) - - Column(drawerSpacing) { - EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) - } - - FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) - - HorizontalDivider( - thickness = DividerThickness, - modifier = Modifier.padding(top = 20.dp), - ) - - Spacer(modifier = StdHorzSpacer) - - ListContent( - modifier = Modifier.fillMaxWidth(), - openSheet, - accountViewModel, - nav, - ) - - Spacer(modifier = Modifier.weight(1f)) - - BottomContent( - accountViewModel.account.userProfile(), - accountViewModel, - nav, - ) + DrawerContentBody(nav, openSheet, accountViewModel) } } } +@Composable +private fun DrawerContentBody( + nav: INav, + openSheet: () -> Unit, + accountViewModel: AccountViewModel, +) { + val onClickUser = { + nav.nav(routeFor(accountViewModel.userProfile())) + nav.closeDrawer() + } + + Column( + Modifier + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + ) { + ProfileContent( + baseAccountUser = accountViewModel.account.userProfile(), + modifier = profileContentHeaderModifier, + accountViewModel, + onClickUser, + ) + + Column(drawerSpacing) { + EditStatusBoxes(accountViewModel.account.userProfile(), accountViewModel, nav) + } + + FollowingAndFollowerCounts(accountViewModel.account, accountViewModel, onClickUser) + + HorizontalDivider( + thickness = DividerThickness, + modifier = Modifier.padding(top = 20.dp), + ) + + Spacer(modifier = StdHorzSpacer) + + ListContent( + modifier = Modifier.fillMaxWidth(), + openSheet, + accountViewModel, + nav, + ) + + Spacer(modifier = Modifier.weight(1f)) + + BottomContent( + accountViewModel.account.userProfile(), + accountViewModel, + nav, + ) + } +} + @Composable fun ProfileContent( baseAccountUser: User, @@ -392,8 +431,11 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } + // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // is always on screen), so the modal close-cancels-editing behavior must not apply there. + val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (nav.drawerState.isClosed) { + if (!isPermanentDrawer && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index efdcfff438..36ca168941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -35,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon @@ -65,12 +66,14 @@ fun UserDrawerSearchTopBar( navigationIcon = { // When this screen sits on top of a back stack (entered via the drawer // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener. + // the bottom nav, which clears the stack), show the drawer opener — + // unless a large-screen shell already shows the drawer (rail avatar or + // permanently docked pane). if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index d4c7090b08..494cce6135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,13 +22,19 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.SheetValue +import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,15 +42,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle +import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent +import com.vitorpamplona.amethyst.ui.navigation.drawer.PermanentDrawerContent import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSelectionDrag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationSidePanel +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -75,6 +91,51 @@ fun AccountSwitcherAndLeftDrawerLayout( } } + when (LocalScreenLayout.current.navigationStyle) { + NavigationStyle.PERMANENT_DRAWER -> + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + + NavigationStyle.NAV_RAIL -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + + NavigationStyle.BOTTOM_BAR -> + ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + } + + // Sheet content + if (openAccountSwitcherBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + openAccountSwitcherBottomSheet = false + } + } + }, + sheetState = sheetState, + ) { + AccountSwitchBottomSheet( + accountViewModel = accountViewModel, + accountSessionManager = accountSessionManager, + ) + } + } +} + +/** + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. + */ +@Composable +private fun ModalDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + showRail: Boolean, + content: @Composable () -> Unit, +) { val orientation = LocalConfiguration.current.orientation val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { @@ -104,30 +165,69 @@ fun AccountSwitcherAndLeftDrawerLayout( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, drawerContent = { - DrawerContent(nav, openSheetFunction, accountViewModel) + DrawerContent(nav, openSheet, accountViewModel) BackHandler(enabled = nav.drawerState.isOpen, nav::closeDrawer) }, - content = content, + content = { + if (showRail) { + Row(Modifier.fillMaxSize()) { + AppNavigationRail(nav, accountViewModel) + VerticalDivider(thickness = DividerThickness) + CenterPane(Modifier.weight(1f), content) + } + } else { + content() + } + }, ) +} - // Sheet content - if (openAccountSwitcherBottomSheet) { - ModalBottomSheet( - onDismissRequest = { - scope - .launch { sheetState.hide() } - .invokeOnCompletion { - if (!sheetState.isVisible) { - openAccountSwitcherBottomSheet = false - } - } - }, - sheetState = sheetState, - ) { - AccountSwitchBottomSheet( - accountViewModel = accountViewModel, - accountSessionManager = accountSessionManager, - ) +/** + * Expanded windows: the drawer is permanently docked on the left, the bottom bar disappears, + * and — when the window is wide enough — the notification feed docks on the right. + */ +@Composable +private fun PermanentDrawerShell( + accountViewModel: AccountViewModel, + nav: Nav, + openSheet: () -> Unit, + content: @Composable () -> Unit, +) { + val navBackStackEntry by nav.controller.currentBackStackEntryAsState() + // The panel duplicates the Notifications screen, so it steps aside while the user is there. + val showPanel = + LocalScreenLayout.current.showsNotificationPanel && + navBackStackEntry?.destination?.hasRoute() != true + + Row(Modifier.fillMaxSize()) { + PermanentDrawerContent(nav, openSheet, accountViewModel) + + VerticalDivider(thickness = DividerThickness) + + CenterPane(Modifier.weight(1f), content) + + if (showPanel) { + VerticalDivider(thickness = DividerThickness) + NotificationSidePanel(accountViewModel, nav) + } + } +} + +/** + * Hosts the navigation content and publishes the side padding feeds need to cap their content + * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + */ +@Composable +private fun CenterPane( + modifier: Modifier, + content: @Composable () -> Unit, +) { + BoxWithConstraints(modifier) { + val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { + Box(Modifier.fillMaxSize()) { + content() + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index ab8ac1842c..0ecafb852b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -20,57 +20,48 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.ui.components.getActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane -@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val act = LocalContext.current.getActivity() - val windowSizeClass = calculateWindowSizeClass(act) + // Decide single- vs two-pane from the pane this screen actually occupies, not the + // window: on large screens the shell already spends width on the rail / permanent + // drawer / notification panel, so window-level size classes would overestimate. + BoxWithConstraints(Modifier.fillMaxSize()) { + val paneWidth = maxWidth - val twoPane by remember(windowSizeClass.widthSizeClass) { - derivedStateOf { - when (windowSizeClass.widthSizeClass) { - WindowWidthSizeClass.Compact -> false - - WindowWidthSizeClass.Expanded, - WindowWidthSizeClass.Medium, - -> true - - else -> false - } + if (paneWidth >= 600.dp) { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = + if (paneWidth >= 840.dp) { + WindowWidthSizeClass.Expanded + } else { + WindowWidthSizeClass.Medium + }, + accountViewModel = accountViewModel, + nav = nav, + ) + } else { + MessagesSinglePane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + accountViewModel = accountViewModel, + nav = nav, + ) } } - - if (twoPane) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = windowSizeClass.widthSizeClass, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { - MessagesSinglePane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - accountViewModel = accountViewModel, - nav = nav, - ) - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 805aaaba77..5d486b9323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -121,14 +123,22 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { + // The left-edge swipe opens the modal drawer; with the drawer permanently docked + // there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + modifier + }, ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 2cf453a10c..c71fb18663 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,22 +21,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane +import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -72,9 +77,6 @@ fun MessagesTwoPane( } } - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -91,58 +93,78 @@ fun MessagesTwoPane( } }, ) { padding -> - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) + // Each pane manages its own width; the shell's center-pane reading cap must not + // re-pad the lists inside them. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + } } } + +@Composable +private fun TwoPaneContent( + padding: PaddingValues, + knownFeedContentState: FeedContentState, + newFeedContentState: FeedContentState, + twoPaneNav: TwoPaneNav, + strategy: TwoPaneStrategy, + accountViewModel: AccountViewModel, + nav: INav, +) { + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index fc2f1a88ab..7621c99dc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -76,6 +76,8 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout +import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.FabBottomBarPadded import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -273,14 +275,22 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { + // The left-edge swipe opens the modal drawer; with the drawer permanently + // docked there is nothing to open, so leave the pager's own gestures alone. + val modalDrawerExists = + LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, modifier = - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ), + if (modalDrawerExists) { + Modifier.zonedDrawerSwipe( + pagerState = pagerState, + openDrawer = nav::openDrawer, + ) + } else { + Modifier + }, ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index e8183abcc3..210a126acc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,6 +36,7 @@ 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.model.TopFilter +import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner @@ -73,7 +74,7 @@ fun NappletsTopBar( navigationIcon = { if (nav.canPop()) { IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else { + } else if (!LocalScreenLayout.current.isLargeScreen) { LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt new file mode 100644 index 0000000000..7f43ec4d17 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -0,0 +1,127 @@ +/* + * 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.notifications + +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState +import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth +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.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size22Modifier +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer + +/** + * The docked notification feed shown on very wide windows, to the right of the center pane. + * It renders the same card feed as the Notifications screen (same last-read marking, so + * reading here clears the new-item dot too); tapping its header opens the full screen, + * which keeps the summary chart and the Following/Everyone split. + */ +@Composable +fun NotificationSidePanel( + accountViewModel: AccountViewModel, + nav: INav, + modifier: Modifier = Modifier, +) { + val notifFeedContentState = accountViewModel.feedStates.notifications + WatchAccountForNotifications(notifFeedContentState, accountViewModel) + + Column( + modifier + .width(NotificationPanelWidth) + .fillMaxHeight() + .windowInsetsPadding( + WindowInsets.systemBars.only( + WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, + ), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } + + HorizontalDivider(thickness = DividerThickness) + + // The panel is its own narrow pane; never apply the center pane's reading-width cap here. + CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { + Box(Modifier.fillMaxWidth()) { + RefresheableBox(notifFeedContentState, true) { + val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) + + RenderCardFeed( + feedContent = notifFeedContentState, + pollContent = accountViewModel.feedStates.notificationsOpenPolls, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = NOTIFICATION_LAST_READ_KEY, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index eb2b94bd87..fa25234ba5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -48,6 +48,18 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) */ val LocalDisappearingBarState = compositionLocalOf { null } +/** + * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a + * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it + * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the + * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to + * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * + * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side + * panels, two-pane splits) must override this back to 0 for their subtree. + */ +val LocalFeedSidePadding = compositionLocalOf { 0.dp } + /** * Merges two [PaddingValues] component-wise, resolving start/end against the current * [LocalLayoutDirection]. @@ -70,7 +82,13 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: - * merges the scaffold's reserved space with the list's own baseline padding. + * merges the scaffold's reserved space with the list's own baseline padding, plus the + * [LocalFeedSidePadding] width cap requested by wide layouts. */ @Composable -fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) +fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { + val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val sidePadding = LocalFeedSidePadding.current + if (sidePadding <= 0.dp) return merged + return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) +} From c72024578e7b952834dc138281b786b660767324 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 04:31:01 +0000 Subject: [PATCH 163/206] fix: harden the large-screen shell against runtime window-size changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from an adversarially verified audit of the large-screen commit. The two most serious bugs shared a root cause: the shell was correct at any fixed size but mishandled the size CHANGING mid-session, which foldables and multi-window make routine (MainActivity handles those configChanges without recreation). Bug fixes: - Hoist the shell content into movableContentOf so crossing a layout tier (fold/unfold, rotate, resize) MOVES the NavHost subtree between shells instead of disposing it — screen state (drafts, pager tabs, expanded states, warm embedded tabs) now survives. - DisappearingScaffold snaps bars back to visible when hiding gets disabled, so chrome scrolled away before a resize is no longer stranded off-screen with no reset path. - ProfileScreen keeps WindowInsets.navigationBars instead of zeroing all content insets; with the bottom bar gone (large screens, and pushed entries on phones) content no longer underlaps the system bar. - New TabReselectCoordinator: AppBottomBar registers each screen's re-tap handler even when the bar renders nothing, and the rail routes selected-item taps through it — restoring tap-current-tab-scrolls-to- top on the rail tier with the screens' existing logic. - NotificationSidePanel now reuses the screen's SingleNotificationsBody (parameterized by scroll-state key), which restores WatchScrollToTop — previously the panel stranded scrolltoTopPending=true on the shared feed state, suppressing later send-to-top requests — and the inbox- relay warning header; it also honors split notifications by showing the Following feed when that setting is on. - Entering the permanent-drawer tier snaps a stale Open drawerState to Closed, so returning to a modal tier no longer pops the drawer uninvited. - MessagesTwoPane keys its TwoPane strategy on the width size class so the split fraction updates when the pane crosses 840dp in place. - The drawer status editor calls onDone() after send/delete, so it can collapse back to the read-only bar in the docked drawer (and no longer waits for a drawer close in the modal one). - The landscape auto-close drawer effect's inverted condition (close-only-when-already-closed, a pre-existing no-op) now closes an open drawer as intended. Structure and performance: - INav.isDrawerDocked models docked-ness explicitly: Nav.openDrawer() no-ops while docked, and consumers stop inferring from a DrawerState that never transitions. - zonedDrawerSwipeIfModal wraps the edge-swipe modifier with the docked check so call sites can't forget it; TopBarNavigationIcon centralizes the back-arrow/avatar-or-nothing leading slot. - The rail reuses AppBottomBar's entry icons (NotifiableIcon, FavoriteEntryIcon, rememberFavoriteIconModel) instead of duplicating them. - MessagesScreen derives its pane size class via WindowSizeClass.calculateFromSize instead of restating the 600/840 breakpoints. - rememberFeedContentPadding folds the scaffold, baseline, and side paddings into one remember slot; the shell quantizes the feed side padding to 8dp steps so continuous resizes don't invalidate every feed per pixel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../ui/feeds/RememberForeverStates.kt | 1 + .../ui/layouts/DisappearingScaffold.kt | 8 ++ .../amethyst/ui/navigation/AppNavigation.kt | 84 ++++++----- .../ui/navigation/bottombars/AppBottomBar.kt | 82 +++++++---- .../bottombars/AppNavigationRail.kt | 55 ++------ .../bottombars/TabReselectCoordinator.kt | 67 +++++++++ .../ui/navigation/drawer/DrawerContent.kt | 13 +- .../ui/navigation/navs/DrawerSwipe.kt | 44 ++++++ .../amethyst/ui/navigation/navs/INav.kt | 8 ++ .../amethyst/ui/navigation/navs/Nav.kt | 8 ++ .../topbars/UserDrawerSearchTopBar.kt | 35 +++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 59 +++++--- .../loggedIn/chats/rooms/MessagesScreen.kt | 35 ++--- .../chats/rooms/feed/ChatroomListTabs.kt | 18 +-- .../chats/rooms/twopane/MessagesTwoPane.kt | 132 ++++++++---------- .../ui/screen/loggedIn/home/HomeScreen.kt | 18 +-- .../loggedIn/napplets/NappletsTopBar.kt | 12 +- .../notifications/NotificationScreen.kt | 11 +- .../notifications/NotificationSidePanel.kt | 56 +++++--- .../screen/loggedIn/profile/ProfileScreen.kt | 6 +- .../commons/ui/layouts/PaddingMerge.kt | 21 ++- 21 files changed, 466 insertions(+), 307 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index fb56c99551..a6595089cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -39,6 +39,7 @@ private data class ScrollState( object ScrollStateKeys { const val NOTIFICATION_SCREEN = "NotificationsFeed" const val NOTIFICATION_SIDE_PANEL = "NotificationsSidePanel" + const val NOTIFICATION_SIDE_PANEL_FOLLOWING = "NotificationsSidePanelFollowing" const val NOTIFICATION_FOLLOWING = "NotificationsFollowingFeed" const val NOTIFICATION_EVERYONE = "NotificationsEveryoneFeed" const val VIDEO_SCREEN = "VideoFeed" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index fd8e8ab850..40b32b4327 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -109,6 +109,14 @@ fun DisappearingScaffold( ImmersiveStatusBarEffect(state) } + // If the bars were scrolled away when hiding got disabled (e.g. the window grew to a + // large tier mid-scroll), nothing above can bring them back — the nested-scroll + // connection and the resume reset are gone. Snap them visible here instead of + // leaving the chrome stranded off-screen. + LaunchedEffect(canHideBars, state) { + if (!canHideBars) state.resetToVisible() + } + // When bars are pinned, skip attaching the nested-scroll connection entirely. // The outer Surface provides the Material container color + onBackground as // LocalContentColor, matching M3 Scaffold's behaviour (without it, default text 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 0e25c9e778..bf8203d6f5 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 @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.rememberScreenLayoutSpec +import com.vitorpamplona.amethyst.ui.navigation.bottombars.LocalTabReselectCoordinator +import com.vitorpamplona.amethyst.ui.navigation.bottombars.TabReselectCoordinator import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.navs.rememberNav @@ -287,54 +289,50 @@ fun AppNavigation( // One layout decision per window size for the whole shell: bottom bar vs rail vs // permanent drawer, plus the docked notification panel. Every screen, bar and panel - // below reads the same spec through LocalScreenLayout. + // below reads the same spec through LocalScreenLayout. The provider wraps this whole + // function body so anything added to AppNavigation later is inside it by construction. val screenLayout = rememberScreenLayoutSpec() + val tabReselectCoordinator = remember { TabReselectCoordinator() } - CompositionLocalProvider(LocalScreenLayout provides screenLayout) { - AppNavigationLayers(accountViewModel, accountSessionManager, nav) - } -} - -@Composable -private fun AppNavigationLayers( - accountViewModel: AccountViewModel, - accountSessionManager: AccountSessionManager, - nav: Nav, -) { - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { - Box(Modifier.fillMaxSize()) { - BuildNavigation(accountViewModel, nav) - // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) - // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded - // preloader below and the full-screen launcher both need. Not API-gated: every device's - // launcher benefits, and it's the only preload step that runs below API 30. - FavoriteAppManifestPreloader(accountViewModel) - // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by - // holding their surfaces attached. Below the drawer (drawn by the layout above) and below - // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems - .collectAsStateWithLifecycle() - EmbeddedTabLayer(bottomBarItems.favoriteIds()) - // Warm every pinned tab at startup so the first tap is instant (content already local). - EmbeddedTabPreloader(accountViewModel) - // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips - // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). - EmbeddedTabThemeWatcher() + CompositionLocalProvider( + LocalScreenLayout provides screenLayout, + LocalTabReselectCoordinator provides tabReselectCoordinator, + ) { + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { + Box(Modifier.fillMaxSize()) { + BuildNavigation(accountViewModel, nav) + // Pull each pinned nsite/napplet's manifest into LocalCache (and keep a device-local copy) + // so its favorite resolves as reliably as a pinned web app's URL — the data the embedded + // preloader below and the full-screen launcher both need. Not API-gated: every device's + // launcher benefits, and it's the only preload step that runs below API 30. + FavoriteAppManifestPreloader(accountViewModel) + // Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by + // holding their surfaces attached. Below the drawer (drawn by the layout above) and below + // dialogs (separate windows). API 30+ only, matching the embedded-surface feature. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems + .collectAsStateWithLifecycle() + EmbeddedTabLayer(bottomBarItems.favoriteIds()) + // Warm every pinned tab at startup so the first tap is instant (content already local). + EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() + } } } + + TrackScreenTime(nav) + NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) + + DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) + DisplayNotifyMessages(accountViewModel, nav) + DisplayCrashMessages(accountViewModel, nav) + DisplayResourceUsageAlert(accountViewModel, nav) + DisplayBroadcastProgress(accountViewModel) + + ObserveIncomingCalls(accountViewModel) } - - TrackScreenTime(nav) - NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) - - DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) - DisplayNotifyMessages(accountViewModel, nav) - DisplayCrashMessages(accountViewModel, nav) - DisplayResourceUsageAlert(accountViewModel, nav) - DisplayBroadcastProgress(accountViewModel) - - ObserveIncomingCalls(accountViewModel) } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt index a635f485ac..930f08d4d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt @@ -36,8 +36,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -74,6 +76,18 @@ fun AppBottomBar( accountViewModel: AccountViewModel, onClick: (Route) -> Unit, ) { + // Publish this screen's re-tap behavior even when the bar renders nothing: on large + // screens the navigation rail routes reselect taps back through the coordinator so the + // same per-screen scroll-to-top/refresh logic runs. + val coordinator = LocalTabReselectCoordinator.current + val latestRoute by rememberUpdatedState(selectedRoute) + val latestOnClick by rememberUpdatedState(onClick) + DisposableEffect(coordinator) { + val handler: (Route) -> Unit = { latestOnClick(it) } + coordinator.register({ latestRoute }, handler) + onDispose { coordinator.unregister(handler) } + } + // Large screens replace the bottom bar with the navigation rail (Medium) or the // permanently docked drawer (Expanded). if (LocalScreenLayout.current.isLargeScreen) return @@ -106,6 +120,43 @@ fun AppBottomBar( } } +/** + * Resolves the icon model for a pinned favorite: a web favorite's captured favicon (else the + * generic globe), an nsite/napplet's verified manifest icon bundled in its own content (the + * iframe sandbox rules out live capture; else the grid glyph). Shared by the bottom bar and + * the navigation rail. + */ +@Composable +internal fun rememberFavoriteIconModel(fav: FavoriteApp): Any? = + when (fav) { + is FavoriteApp.WebApp -> { + // Captured favicons, keyed so the icon appears once the site's capture lands. + val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + remember(fav, iconKeys) { + OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) + } + } + + is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) + } + +/** The icon block for a pinned favorite entry, shared by the bottom bar and the rail. */ +@Composable +internal fun FavoriteEntryIcon( + fav: FavoriteApp, + selected: Boolean, + iconModel: Any?, +) { + Box(Size27Modifier, contentAlignment = Alignment.Center) { + FavoriteAppIcon( + app = fav, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, + modifier = Size25Modifier, + iconModel = iconModel, + ) + } +} + @Composable private fun RenderBottomMenu( items: List, @@ -117,9 +168,6 @@ private fun RenderBottomMenu( // Index favorites by id so resolving each Favorite entry is a map lookup, not a per-entry scan. val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() - Column( modifier = Modifier @@ -152,17 +200,7 @@ private fun RenderBottomMenu( is FavoriteApp.WebApp -> Route.WebApp(fav.url) is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate) } - // A web favorite uses its captured favicon; an nsite/napplet uses the verified - // icon blob bundled in its own content (the iframe sandbox rules out live capture). - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } - FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav) + FavoriteNavItem(destination == selectedRoute, fav, rememberFavoriteIconModel(fav), destination, nav) } } } @@ -180,18 +218,7 @@ private fun RowScope.FavoriteNavItem( ) { NavigationBarItem( alwaysShowLabel = false, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - // A web favorite's captured favicon (else the globe); an nsite/napplet's manifest icon (else - // the grid glyph). - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) - } - }, + icon = { FavoriteEntryIcon(fav, selected, iconModel) }, // No label — favorite tabs match the built-in items, which show icon only. selected = selected, onClick = { nav(destination) }, @@ -221,8 +248,9 @@ private fun RowScope.HasNewItemsIcon( ) } +/** The icon block for a built-in entry (catalog icon + new-items dot), shared by the bottom bar and the rail. */ @Composable -private fun NotifiableIcon( +internal fun NotifiableIcon( selected: Boolean, def: NavBarItemDef, destination: Route, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt index d90eb99281..be8c77035e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppNavigationRail.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.navigation.bottombars -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -35,28 +34,20 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp -import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry -import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.Size25Modifier -import com.vitorpamplona.amethyst.ui.theme.Size27Modifier -import com.vitorpamplona.amethyst.ui.theme.onSurface65 /** * Medium-width windows: a left rail that carries the same user-configured destinations as the * phone bottom bar ([BottomBarEntry] list, built-ins and pinned favorites interleaved), so the * user's customization and new-item dots carry over. The header avatar opens the modal drawer, - * mirroring the avatar button in the phone top bars. + * mirroring the avatar button in the phone top bars. Re-tapping the selected item routes + * through [TabReselectCoordinator] to the screen's own scroll-to-top/refresh handler. */ @Composable fun AppNavigationRail( @@ -68,8 +59,7 @@ fun AppNavigationRail( val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val favoritesById = remember(favorites) { favorites.associateBy { it.id } } - // Captured favicons, so a pinned web favorite shows the site's icon instead of the generic globe. - val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle() + val reselectCoordinator = LocalTabReselectCoordinator.current val navBackStackEntry by nav.controller.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -93,18 +83,14 @@ fun AppNavigationRail( val selected = currentDestination?.hasRoute(destination::class) == true NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - Icon( - symbol = def.icon, - contentDescription = stringRes(def.labelRes), - modifier = Size25Modifier, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - ) - AddNotifIconIfNeeded(destination, accountViewModel, Modifier.align(Alignment.TopEnd)) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { NotifiableIcon(selected, def, destination, accountViewModel) }, ) } @@ -125,27 +111,16 @@ fun AppNavigationRail( else -> false } } - val iconModel = - when (fav) { - is FavoriteApp.WebApp -> - remember(fav, iconKeys) { - OmniboxInput.hostOf(fav.url)?.let(BrowserIconRegistry::iconModelFor) - } - is FavoriteApp.NostrApp -> rememberNappletIconModel(fav.coordinate) - } NavigationRailItem( selected = selected, - onClick = { if (!selected) nav.navBottomBar(destination) }, - icon = { - Box(Size27Modifier, contentAlignment = Alignment.Center) { - FavoriteAppIcon( - app = fav, - tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface65, - modifier = Size25Modifier, - iconModel = iconModel, - ) + onClick = { + if (selected) { + reselectCoordinator.reselect(destination) + } else { + nav.navBottomBar(destination) } }, + icon = { FavoriteEntryIcon(fav, selected, rememberFavoriteIconModel(fav)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt new file mode 100644 index 0000000000..b67bc5c66c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/TabReselectCoordinator.kt @@ -0,0 +1,67 @@ +/* + * 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.navigation.bottombars + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import com.vitorpamplona.amethyst.ui.navigation.routes.Route + +/** + * Bridges the shell-level [AppNavigationRail] to the per-screen re-tap behaviors that live in + * each screen's [AppBottomBar] onClick lambda (scroll-to-top, feed refresh, ...). + * + * On phones the bottom bar invokes the screen's lambda directly. On large screens the bar + * renders nothing, but it still registers the screen's lambda here; when the user taps the + * rail item that is already selected, the rail routes the tap back through [reselect] so the + * exact same per-screen logic runs. + */ +@Stable +class TabReselectCoordinator { + private var currentRoute: (() -> Route?)? = null + private var currentHandler: ((Route) -> Unit)? = null + + fun register( + route: () -> Route?, + handler: (Route) -> Unit, + ) { + currentRoute = route + currentHandler = handler + } + + /** Unregisters only if [handler] is still the active one, so a newly composed screen's + * registration is not torn down by the outgoing screen's dispose during a transition. */ + fun unregister(handler: (Route) -> Unit) { + if (currentHandler === handler) { + currentHandler = null + currentRoute = null + } + } + + /** Invokes the active tab root's handler if it owns [route]. Returns true if handled. */ + fun reselect(route: Route): Boolean { + val handler = currentHandler ?: return false + if (currentRoute?.invoke() != route) return false + handler(route) + return true + } +} + +val LocalTabReselectCoordinator = staticCompositionLocalOf { TabReselectCoordinator() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 2c2ed264f8..bb6f93f288 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -104,8 +104,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostStatus import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems @@ -431,11 +429,10 @@ fun StatusEditBar( val currentStatus = remember { mutableStateOf(savedStatus ?: "") } - // In the permanent drawer the DrawerState never opens (it stays Closed while the drawer + // In the docked drawer the DrawerState never opens (it stays Closed while the drawer // is always on screen), so the modal close-cancels-editing behavior must not apply there. - val isPermanentDrawer = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER LaunchedEffect(nav.drawerState.isClosed) { - if (!isPermanentDrawer && nav.drawerState.isClosed) { + if (!nav.isDrawerDocked && nav.drawerState.isClosed) { focusManager.clearFocus(true) onDone() } else { @@ -469,6 +466,10 @@ fun StatusEditBar( } focusManager.clearFocus(true) + // Collapse back to the read-only bar: in the docked drawer no + // drawer-close will ever do it, and in the modal drawer this beats + // staying in edit mode until the drawer closes. + onDone() }, ), singleLine = true, @@ -484,12 +485,14 @@ fun StatusEditBar( accountViewModel.updateStatus(address, currentStatus.value) } focusManager.clearFocus(true) + onDone() } } else { if (address != null) { UserStatusDeleteButton { accountViewModel.deleteStatus(address) focusManager.clearFocus(true) + onDone() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt new file mode 100644 index 0000000000..0380e182b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/DrawerSwipe.kt @@ -0,0 +1,44 @@ +/* + * 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.navigation.navs + +import androidx.compose.foundation.pager.PagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe + +/** + * [zonedDrawerSwipe] gated on the drawer actually being modal. With the drawer permanently + * docked ([INav.isDrawerDocked]) there is nothing to open, so the left-edge swipe zone is + * not attached at all and the pager keeps its own gestures. Use this instead of calling + * [zonedDrawerSwipe] directly from screens — the guard then can't be forgotten at new + * call sites. + */ +@Composable +fun Modifier.zonedDrawerSwipeIfModal( + pagerState: PagerState, + nav: INav, +): Modifier = + if (nav.isDrawerDocked) { + this + } else { + zonedDrawerSwipe(pagerState, nav::openDrawer) + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt index f7ceff737b..229b86e212 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/INav.kt @@ -32,6 +32,14 @@ interface INav { val navigationScope: CoroutineScope val drawerState: DrawerState + /** + * True while the shell renders the drawer permanently docked (Expanded windows). In that + * mode [drawerState] never transitions — it stays [androidx.compose.material3.DrawerValue.Closed] + * while the drawer is visibly on screen — so drawer consumers (edge swipes, open buttons, + * close-driven effects) should consult this instead of inferring from [drawerState]. + */ + val isDrawerDocked: Boolean get() = false + fun closeDrawer() fun openDrawer() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 05ca546fd7..1578cbc853 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -26,6 +26,8 @@ import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -44,11 +46,17 @@ class Nav( ) : INav { override val drawerState = DrawerState(DrawerValue.Closed) + /** Set by the shell when the layout tier docks the drawer permanently. */ + override var isDrawerDocked: Boolean by mutableStateOf(false) + override fun closeDrawer() { navigationScope.launch { drawerState.close() } } override fun openDrawer() { + // Nothing renders the modal drawer while it is docked; opening the state would + // only strand an Open value for the next modal tier to trip over. + if (isDrawerDocked) return navigationScope.launch { drawerState.open() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index 36ca168941..da7fcbbb4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -63,20 +63,7 @@ fun UserDrawerSearchTopBar( content() } }, - navigationIcon = { - // When this screen sits on top of a back stack (entered via the drawer - // or any deep link), show a back arrow. When it's the root (entered via - // the bottom nav, which clears the stack), show the drawer opener — - // unless a large-screen shell already shows the drawer (rail avatar or - // permanently docked pane). - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { - ArrowBackIcon() - } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.Search) }) { SearchIcon(modifier = Size22Modifier, MaterialTheme.colorScheme.placeholderText) @@ -85,6 +72,26 @@ fun UserDrawerSearchTopBar( ) } +/** + * The standard leading slot for root-capable top bars: a back arrow when the screen sits on + * top of a back stack; otherwise the avatar drawer-opener — unless a large-screen shell + * already presents the drawer (rail avatar or permanently docked pane), in which case + * nothing is shown. Use this instead of hand-writing the branches per top bar. + */ +@Composable +fun TopBarNavigationIcon( + accountViewModel: AccountViewModel, + nav: INav, +) { + if (nav.canPop()) { + IconButton(onClick = nav::popBack) { + ArrowBackIcon() + } + } else if (!LocalScreenLayout.current.isLargeScreen) { + LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) + } +} + @Composable internal fun LoggedInUserPictureDrawer( accountViewModel: AccountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 494cce6135..74733ffad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api @@ -37,13 +37,16 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState @@ -91,15 +94,30 @@ fun AccountSwitcherAndLeftDrawerLayout( } } - when (LocalScreenLayout.current.navigationStyle) { - NavigationStyle.PERMANENT_DRAWER -> - PermanentDrawerShell(accountViewModel, nav, openSheetFunction, content) + // The layout tier can change while the app runs (fold/unfold, rotation, window resize) + // and the shells below place `content` at different composition positions. Movable + // content lets the whole NavHost subtree MOVE between those positions instead of being + // disposed and rebuilt, preserving every screen's remember/rememberSaveable state. + val currentContent by rememberUpdatedState(content) + val movableContent = remember { movableContentOf { currentContent() } } - NavigationStyle.NAV_RAIL -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = true, content) + val docked = LocalScreenLayout.current.navigationStyle == NavigationStyle.PERMANENT_DRAWER - NavigationStyle.BOTTOM_BAR -> - ModalDrawerShell(accountViewModel, nav, openSheetFunction, showRail = false, content) + // Publish docked-ness on the Nav so drawer consumers (openDrawer, edge swipes, the + // status editor) can behave correctly without each re-deriving the layout tier. + LaunchedEffect(docked) { + nav.isDrawerDocked = docked + // Entering the permanent tier with the modal drawer still Open would otherwise + // carry the stale Open value back to the modal tier and pop the drawer uninvited. + if (docked && !nav.drawerState.isClosed) { + nav.drawerState.snapTo(DrawerValue.Closed) + } + } + + if (docked) { + PermanentDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) + } else { + ModalDrawerShell(accountViewModel, nav, openSheetFunction, movableContent) } // Sheet content @@ -125,7 +143,7 @@ fun AccountSwitcherAndLeftDrawerLayout( } /** - * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium a + * Compact and Medium windows: the drawer slides in as a modal sheet. On Medium an * [AppNavigationRail] sits at the left edge in place of the phone bottom bar. */ @Composable @@ -133,15 +151,13 @@ private fun ModalDrawerShell( accountViewModel: AccountViewModel, nav: Nav, openSheet: () -> Unit, - showRail: Boolean, content: @Composable () -> Unit, ) { val orientation = LocalConfiguration.current.orientation - val currentDrawerState = nav.drawerState.currentValue LaunchedEffect(key1 = orientation) { - if ( - orientation == Configuration.ORIENTATION_LANDSCAPE && currentDrawerState == DrawerValue.Closed - ) { + // Dismiss an open drawer when the device rotates to landscape; the layout + // underneath changes too much for the sheet to stay meaningful. + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { nav.drawerState.close() } } @@ -161,6 +177,8 @@ private fun ModalDrawerShell( // so a handle drag near the left edge (or the auto-scroll edge drag) doesn't open the drawer. !EmbeddedSelectionDrag.dragging + val showRail = LocalScreenLayout.current.navigationStyle == NavigationStyle.NAV_RAIL + ModalNavigationDrawer( drawerState = nav.drawerState, gesturesEnabled = drawerGesturesEnabled, @@ -213,6 +231,10 @@ private fun PermanentDrawerShell( } } +/** Step size for the feed side padding, so continuous window resizes republish the value + * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ +private val SidePaddingQuantum = 8.dp + /** * Hosts the navigation content and publishes the side padding feeds need to cap their content * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. @@ -222,12 +244,11 @@ private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier) { - val sidePadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + BoxWithConstraints(modifier.fillMaxHeight()) { + val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) + val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - Box(Modifier.fillMaxSize()) { - content() - } + content() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt index 0ecafb852b..514fccede2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/MessagesScreen.kt @@ -22,15 +22,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.DpSize import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.singlepane.MessagesSinglePane import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane.MessagesTwoPane +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun MessagesScreen( accountViewModel: AccountViewModel, @@ -39,29 +42,29 @@ fun MessagesScreen( // Decide single- vs two-pane from the pane this screen actually occupies, not the // window: on large screens the shell already spends width on the rail / permanent // drawer / notification panel, so window-level size classes would overestimate. + // calculateFromSize keeps the Compact/Medium/Expanded breakpoints in one place + // (Material's) instead of restating 600/840 here. BoxWithConstraints(Modifier.fillMaxSize()) { - val paneWidth = maxWidth + val paneWidthClass = + WindowSizeClass + .calculateFromSize(DpSize(maxWidth, maxHeight)) + .widthSizeClass - if (paneWidth >= 600.dp) { - MessagesTwoPane( - knownFeedContentState = accountViewModel.feedStates.dmKnown, - newFeedContentState = accountViewModel.feedStates.dmNew, - widthSizeClass = - if (paneWidth >= 840.dp) { - WindowWidthSizeClass.Expanded - } else { - WindowWidthSizeClass.Medium - }, - accountViewModel = accountViewModel, - nav = nav, - ) - } else { + if (paneWidthClass == WindowWidthSizeClass.Compact) { MessagesSinglePane( knownFeedContentState = accountViewModel.feedStates.dmKnown, newFeedContentState = accountViewModel.feedStates.dmNew, accountViewModel = accountViewModel, nav = nav, ) + } else { + MessagesTwoPane( + knownFeedContentState = accountViewModel.feedStates.dmKnown, + newFeedContentState = accountViewModel.feedStates.dmNew, + widthSizeClass = paneWidthClass, + accountViewModel = accountViewModel, + nav = nav, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index 5d486b9323..157702ec24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -42,14 +42,12 @@ import androidx.compose.ui.Modifier 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.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp @@ -123,22 +121,10 @@ fun MessagesPager( nav: INav, modifier: Modifier = Modifier, ) { - // The left-edge swipe opens the modal drawer; with the drawer permanently docked - // there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - modifier - }, + modifier = modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> ChatroomListFeedView( feedContentState = tabs[page].feedContentState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index c71fb18663..9d16c3ea38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.twopane import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -38,7 +37,6 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane -import com.google.accompanist.adaptive.TwoPaneStrategy import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding @@ -68,8 +66,11 @@ fun MessagesTwoPane( val scope = rememberCoroutineScope() val twoPaneNav = remember { TwoPaneNav(nav, scope) } + // Keyed on the size class: the pane can cross the Medium/Expanded boundary while this + // screen stays composed (window resize, the notification panel docking/undocking), and + // an unkeyed remember would keep serving the stale split fraction. val strategy = - remember { + remember(widthSizeClass) { if (widthSizeClass == WindowWidthSizeClass.Expanded) { HorizontalTwoPaneStrategy(splitFraction = 1f / 3f) } else { @@ -77,6 +78,9 @@ fun MessagesTwoPane( } } + val act = LocalContext.current.getActivity() + val displayFeatures = calculateDisplayFeatures(act) + Scaffold( modifier = Modifier.imePadding(), topBar = { @@ -96,75 +100,59 @@ fun MessagesTwoPane( // Each pane manages its own width; the shell's center-pane reading cap must not // re-pad the lists inside them. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPaneContent(padding, knownFeedContentState, newFeedContentState, twoPaneNav, strategy, accountViewModel, nav) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) + + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) + + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } } - -@Composable -private fun TwoPaneContent( - padding: PaddingValues, - knownFeedContentState: FeedContentState, - newFeedContentState: FeedContentState, - twoPaneNav: TwoPaneNav, - strategy: TwoPaneStrategy, - accountViewModel: AccountViewModel, - nav: INav, -) { - val act = LocalContext.current.getActivity() - val displayFeatures = calculateDisplayFeatures(act) - - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) - - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) - - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) - } - } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 7621c99dc5..9dcf4352e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.commons.ui.components.zonedDrawerSwipe import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.layouts.rememberFeedContentPadding @@ -76,11 +75,10 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout -import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle 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.navs.zonedDrawerSwipeIfModal import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -275,22 +273,10 @@ private fun HomePages( // scaffold padding from LocalDisappearingScaffoldPadding via // rememberFeedContentPadding, so feed items still scroll behind the bars. Box(modifier = Modifier.fillMaxSize()) { - // The left-edge swipe opens the modal drawer; with the drawer permanently - // docked there is nothing to open, so leave the pager's own gestures alone. - val modalDrawerExists = - LocalScreenLayout.current.navigationStyle != NavigationStyle.PERMANENT_DRAWER HorizontalPager( state = pagerState, userScrollEnabled = true, - modifier = - if (modalDrawerExists) { - Modifier.zonedDrawerSwipe( - pagerState = pagerState, - openDrawer = nav::openDrawer, - ) - } else { - Modifier - }, + modifier = Modifier.zonedDrawerSwipeIfModal(pagerState, nav), ) { page -> HomeFeeds( feedState = tabs[page].feedState, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt index 210a126acc..96dfa7982b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsTopBar.kt @@ -36,13 +36,11 @@ 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.model.TopFilter -import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner -import com.vitorpamplona.amethyst.ui.navigation.topbars.LoggedInUserPictureDrawer import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarNavigationIcon import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -71,13 +69,7 @@ fun NappletsTopBar( NappletsTopNavFilterBar(accountViewModel) } }, - navigationIcon = { - if (nav.canPop()) { - IconButton(onClick = nav::popBack) { ArrowBackIcon() } - } else if (!LocalScreenLayout.current.isLargeScreen) { - LoggedInUserPictureDrawer(accountViewModel, nav::openDrawer) - } - }, + navigationIcon = { TopBarNavigationIcon(accountViewModel, nav) }, actions = { IconButton(onClick = { nav.nav(Route.ConnectedApps) }) { Icon(MaterialSymbols.Tune, contentDescription = stringResource(R.string.napplet_manage_permissions)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt index b3e6823b45..d9ad7b2826 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationScreen.kt @@ -214,16 +214,23 @@ private fun SplitNotificationsScaffold( } } +/** + * The refreshable notifications card feed (list + scroll-to-top watcher + inbox-relay + * warning header). Shared between the Notifications screen and the docked + * [NotificationSidePanel], parameterized by the persisted scroll-state key so each + * surface keeps its own position. + */ @Composable -private fun SingleNotificationsBody( +internal fun SingleNotificationsBody( notifFeedContentState: CardFeedContentState, notifPolls: OpenPollsState, scrollToEventId: String?, accountViewModel: AccountViewModel, nav: INav, + scrollStateKey: String = ScrollStateKeys.NOTIFICATION_SCREEN, ) { RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SCREEN) + val listState = rememberForeverLazyListState(scrollStateKey) WatchScrollToTop(notifFeedContentState, listState) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 7f43ec4d17..598be6ef1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -39,30 +39,35 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +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.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys -import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyListState import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth 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.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.Size12dp +import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer /** * The docked notification feed shown on very wide windows, to the right of the center pane. - * It renders the same card feed as the Notifications screen (same last-read marking, so - * reading here clears the new-item dot too); tapping its header opens the full screen, - * which keeps the summary chart and the Following/Everyone split. + * It renders the same card feed body as the Notifications screen (same last-read marking — + * cards mark themselves read as they become visible here, exactly as they would on the + * screen, so the new-item dot only lights for items the panel hasn't displayed). When the + * user has split notifications enabled, the panel shows the Following feed to match the + * screen's default tab. Tapping the header opens the full screen, which adds the summary + * chart and the Following/Everyone tabs. */ @Composable fun NotificationSidePanel( @@ -70,7 +75,22 @@ fun NotificationSidePanel( nav: INav, modifier: Modifier = Modifier, ) { - val notifFeedContentState = accountViewModel.feedStates.notifications + val split by accountViewModel.account.settings.splitNotificationsEnabled + .collectAsStateWithLifecycle() + + val notifFeedContentState = + if (split) { + accountViewModel.feedStates.notificationsFollowing + } else { + accountViewModel.feedStates.notifications + } + val scrollStateKey = + if (split) { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL_FOLLOWING + } else { + ScrollStateKeys.NOTIFICATION_SIDE_PANEL + } + WatchAccountForNotifications(notifFeedContentState, accountViewModel) Column( @@ -88,7 +108,7 @@ fun NotificationSidePanel( Modifier .fillMaxWidth() .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = Size16dp, vertical = Size12dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( @@ -108,19 +128,15 @@ fun NotificationSidePanel( // The panel is its own narrow pane; never apply the center pane's reading-width cap here. CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.fillMaxWidth()) { - RefresheableBox(notifFeedContentState, true) { - val listState = rememberForeverLazyListState(ScrollStateKeys.NOTIFICATION_SIDE_PANEL) - - RenderCardFeed( - feedContent = notifFeedContentState, - pollContent = accountViewModel.feedStates.notificationsOpenPolls, - accountViewModel = accountViewModel, - listState = listState, - nav = nav, - routeForLastRead = NOTIFICATION_LAST_READ_KEY, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 78bd24ab41..3a1cf39a2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.pager.HorizontalPager @@ -335,7 +336,10 @@ fun ProfileScreen( topBar = { ProfileTopBar(baseUser, accountViewModel, nav) }, - contentWindowInsets = WindowInsets(0), + // Status-bar handling is done inside RenderSurface, but the bottom inset must stay: + // when AppBottomBar renders nothing (pushed entries on phones, all large-screen + // tiers) this inset is the only thing keeping content clear of the system nav bar. + contentWindowInsets = WindowInsets.navigationBars, bottomBar = { AppBottomBar( Route.Profile(accountViewModel.userProfile().pubkeyHex), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index fa25234ba5..f66a2c42a9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -55,8 +55,9 @@ val LocalDisappearingBarState = compositionLocalOf { null * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). * - * Full-bleed surfaces (video/shorts pagers) and secondary panes with their own width (side - * panels, two-pane splits) must override this back to 0 for their subtree. + * Panes that manage their own width — side panels, the panes of a two-pane split — must + * override this back to 0 for their subtree: the shell's value was computed against the full + * center pane and would over-pad a narrower list. */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } @@ -83,12 +84,20 @@ fun rememberMergedPadding( /** * Convenience for inner LazyColumns/LazyVerticalGrids inside a [DisappearingScaffold]: * merges the scaffold's reserved space with the list's own baseline padding, plus the - * [LocalFeedSidePadding] width cap requested by wide layouts. + * [LocalFeedSidePadding] width cap requested by wide layouts — all folded into a single + * remember slot, since this runs in every feed on every recomposition. */ @Composable fun rememberFeedContentPadding(inner: PaddingValues): PaddingValues { - val merged = rememberMergedPadding(LocalDisappearingScaffoldPadding.current, inner) + val outer = LocalDisappearingScaffoldPadding.current val sidePadding = LocalFeedSidePadding.current - if (sidePadding <= 0.dp) return merged - return rememberMergedPadding(merged, PaddingValues(start = sidePadding, end = sidePadding)) + val layoutDirection = LocalLayoutDirection.current + return remember(outer, inner, sidePadding, layoutDirection) { + PaddingValues( + start = outer.calculateStartPadding(layoutDirection) + inner.calculateStartPadding(layoutDirection) + sidePadding, + top = outer.calculateTopPadding() + inner.calculateTopPadding(), + end = outer.calculateEndPadding(layoutDirection) + inner.calculateEndPadding(layoutDirection) + sidePadding, + bottom = outer.calculateBottomPadding() + inner.calculateBottomPadding(), + ) + } } From 6b6f0fd2aefe9176fd3a20ab153f691530fa441f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:41:34 +0000 Subject: [PATCH 164/206] feat: cap every screen to the reading-column width on wide panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capping only the feeds' contentPadding left top bars, list screens, bookmarks and settings stretched across the whole center pane. Move the cap up a level: every NavHost destination is wrapped in CappedScreenContent (600dp, centered) through the shared route builders in NavigationEffects, so each screen's entire surface — top bar, tabs, content — shares one reading column, on all ~200 destinations at once. Opt-outs at registration: Route.Message keeps the full pane for its two-pane list/conversation split, and Browser/WebApp/NostrApp stay full-pane so the warm EmbeddedTabLayer surfaces keep lining up. This supersedes the LocalFeedSidePadding-based capping on Android: the shell no longer provides side padding (CenterPane is a plain Box again) and the now-dead overrides in MessagesTwoPane and NotificationSidePanel are removed. The commons local stays, documented as the padding-based alternative for hosts like a desktop reading column where gutters should still scroll. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/ScreenLayout.kt | 39 ++++++- .../amethyst/ui/navigation/AppNavigation.kt | 14 +-- .../ui/navigation/NavigationEffects.kt | 66 +++++++++-- .../AccountSwitcherAndLeftDrawerLayout.kt | 24 +--- .../chats/rooms/twopane/MessagesTwoPane.kt | 103 ++++++++---------- .../notifications/NotificationSidePanel.kt | 24 ++-- .../commons/ui/layouts/PaddingMerge.kt | 16 +-- 7 files changed, 169 insertions(+), 117 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt index ae373a5ed6..57b66bc8b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ScreenLayout.kt @@ -20,6 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.layouts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass @@ -27,6 +32,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.components.getActivity @@ -80,12 +87,38 @@ val PermanentDrawerWidth = 300.dp val NotificationPanelWidth = 360.dp /** - * Maximum width of a feed's content column inside a wide center pane. Wider than this, - * feeds center themselves via [com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding]; - * the scroll surface itself stays full-pane so pull-to-refresh and scrolling work edge to edge. + * Maximum width of a screen's content column inside a wide center pane. Every NavHost + * destination is wrapped in [CappedScreenContent] (via the builders in NavigationEffects), + * so the whole screen — top bar, tabs, feed, settings rows — shares one centered reading + * column instead of stretching across the pane. Screens that genuinely need the full pane + * (Messages' two-pane split, the embedded browser surfaces) opt out at registration. */ val FeedContentMaxWidth = 600.dp +/** + * Centers a destination's content at [FeedContentMaxWidth]. The outer box paints the theme + * background so the gutters match the screens' own surfaces; on Compact windows the cap is + * wider than the pane and this is a visual no-op. + */ +@Composable +fun CappedScreenContent(content: @Composable () -> Unit) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .widthIn(max = FeedContentMaxWidth) + .fillMaxSize(), + ) { + content() + } + } +} + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @Composable fun rememberScreenLayoutSpec(): ScreenLayoutSpec { 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 bf8203d6f5..087ebdb43c 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 @@ -380,9 +380,9 @@ fun BuildNavigation( enterTransition = { fadeIn(animationSpec = tween(200)) }, exitTransition = { fadeOut(animationSpec = tween(200)) }, ) { - composable { HomeScreen(accountViewModel, nav) } + composableCapped { HomeScreen(accountViewModel, nav) } composable { MessagesScreen(accountViewModel, nav) } - composable { VideoScreen(accountViewModel, nav) } + composableCapped { VideoScreen(accountViewModel, nav) } composableArgs { DiscoverScreen(it.initialTab, accountViewModel, nav) } composableArgs { NotificationScreen(it.scrollToEventId, accountViewModel, nav) } composableFromEnd { PollsScreen(accountViewModel, nav) } @@ -399,10 +399,10 @@ fun BuildNavigation( composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } composableFromEnd { NappletsScreen(accountViewModel, nav) } composableFromEnd { NsitesScreen(accountViewModel, nav) } - composableFromEnd { BrowserScreen(accountViewModel, nav) } + composableFromEnd(capWidth = false) { BrowserScreen(accountViewModel, nav) } composableFromEnd { FavoriteAppsScreen(accountViewModel, nav) } - composableFromEndArgs { WebAppScreen(it.url, accountViewModel, nav) } - composableFromEndArgs { NostrAppScreen(it.coordinate, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { WebAppScreen(it.url, accountViewModel, nav) } + composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } @@ -441,7 +441,7 @@ fun BuildNavigation( composableFromEndArgs { NewMusicPlaylistScreen(editDTag = it.dTag, accountViewModel = accountViewModel, nav = nav) } composableFromEndArgs { AddToMusicPlaylistSheet(trackAddress = it.trackAddress, accountViewModel = accountViewModel, nav = nav) } composableFromEnd { NewHlsVideoScreen(accountViewModel, nav) } - composable { ChessLobbyScreen(accountViewModel, nav) } + composableCapped { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { WalletScreen(accountViewModel, nav) } composableFromEndArgs { WalletSendScreen(it.walletId, accountViewModel, nav) } @@ -494,7 +494,7 @@ fun BuildNavigation( composableFromBottomArgs { TopUpMintScreen(it.mintUrl, accountViewModel, nav) } composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } - composable { SearchScreen(accountViewModel, nav) } + composableCapped { SearchScreen(accountViewModel, nav) } composableFromEnd { AllSettingsScreen(accountViewModel, nav) } composableFromEnd { AccountBackupScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index cf4a4ee7e9..8b16865a31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -33,6 +33,7 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent // Per-entry hint stamped by Nav.navBottomBar marking that the entry was // reached via a bottom-nav tab. Used in two places: @@ -44,41 +45,84 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true -inline fun NavGraphBuilder.composableFromEnd(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +/** + * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless + * it opted out with `capWidth = false`. Every builder below routes through this, so all + * destinations — top bars included — share the centered column on large screens by default. + */ +@Composable +fun MaybeCappedScreen( + capWidth: Boolean, + content: @Composable () -> Unit, +) { + if (capWidth) { + CappedScreenContent(content) + } else { + content() + } +} + +/** Stock fade-transition destination, capped to the reading-column width on wide panes. */ +inline fun NavGraphBuilder.composableCapped(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { + composable { entry -> + CappedScreenContent { content(entry) } + } +} + +inline fun NavGraphBuilder.composableFromEnd( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromEndArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromEnd { +inline fun NavGraphBuilder.composableFromEndArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromEnd(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableFromBottom(noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit) { +inline fun NavGraphBuilder.composableFromBottom( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, +) { composable( enterTransition = { slideInVerticallyFromBottom }, exitTransition = { scaleOut }, popEnterTransition = { scaleIn }, popExitTransition = { slideOutVerticallyToBottom }, - content = content, + content = { entry -> + MaybeCappedScreen(capWidth) { content(entry) } + }, ) } -inline fun NavGraphBuilder.composableFromBottomArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composableFromBottom { +inline fun NavGraphBuilder.composableFromBottomArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composableFromBottom(capWidth) { content(it.toRoute()) } } -inline fun NavGraphBuilder.composableArgs(noinline content: @Composable AnimatedContentScope.(T) -> Unit) { - composable { - content(it.toRoute()) +inline fun NavGraphBuilder.composableArgs( + capWidth: Boolean = true, + noinline content: @Composable AnimatedContentScope.(T) -> Unit, +) { + composable { entry -> + MaybeCappedScreen(capWidth) { content(entry.toRoute()) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 74733ffad4..dbeddda760 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.res.Configuration import androidx.activity.compose.BackHandler -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -34,7 +34,6 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.VerticalDivider import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.movableContentOf @@ -46,12 +45,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding -import com.vitorpamplona.amethyst.ui.layouts.FeedContentMaxWidth import com.vitorpamplona.amethyst.ui.layouts.LocalScreenLayout import com.vitorpamplona.amethyst.ui.layouts.NavigationStyle import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppNavigationRail @@ -231,24 +226,17 @@ private fun PermanentDrawerShell( } } -/** Step size for the feed side padding, so continuous window resizes republish the value - * (and invalidate every feed reading it) at most once per step instead of once per pixel. */ -private val SidePaddingQuantum = 8.dp - /** - * Hosts the navigation content and publishes the side padding feeds need to cap their content - * at [FeedContentMaxWidth] within this pane, via [LocalFeedSidePadding]. + * Hosts the navigation content. Screen width capping happens per NavHost destination + * ([com.vitorpamplona.amethyst.ui.layouts.CappedScreenContent] via the NavigationEffects + * builders), so this pane just claims the leftover row width. */ @Composable private fun CenterPane( modifier: Modifier, content: @Composable () -> Unit, ) { - BoxWithConstraints(modifier.fillMaxHeight()) { - val rawPadding = ((maxWidth - FeedContentMaxWidth) / 2).coerceAtLeast(0.dp) - val sidePadding = Dp((rawPadding.value / SidePaddingQuantum.value).toInt() * SidePaddingQuantum.value) - CompositionLocalProvider(LocalFeedSidePadding provides sidePadding) { - content() - } + Box(modifier.fillMaxHeight()) { + content() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt index 9d16c3ea38..826d46673d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/MessagesTwoPane.kt @@ -27,19 +27,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import com.google.accompanist.adaptive.FoldAwareConfiguration import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.calculateDisplayFeatures import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -97,62 +94,58 @@ fun MessagesTwoPane( } }, ) { padding -> - // Each pane manages its own width; the shell's center-pane reading cap must not - // re-pad the lists inside them. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - TwoPane( - first = { - Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { - RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) + TwoPane( + first = { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.BottomEnd) { + RelayGroupMyJoinedGroupsSubscription(accountViewModel.dataSources().relayGroupMyJoinedGroups, accountViewModel) - // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a - // cache hit when those groups surface in discovery or any gated surface. - WarmJoinedRelayGroupNip11(accountViewModel) + // Pre-warm NIP-11 for joined groups' host relays so the relay-signed check is a + // cache hit when those groups surface in discovery or any gated surface. + WarmJoinedRelayGroupNip11(accountViewModel) - // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined - // groups (or per-relay rows in grouped mode) are woven into the list itself. - ChatroomList( - knownFeedContentState, - newFeedContentState, - accountViewModel, - twoPaneNav, - ) + // The inline-vs-grouped NIP-29 preference lives in Settings › Messages; joined + // groups (or per-relay rows in grouped mode) are woven into the list itself. + ChatroomList( + knownFeedContentState, + newFeedContentState, + accountViewModel, + twoPaneNav, + ) - Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { - ChannelFabColumn(nav) + Box(Modifier.padding(Size20dp), contentAlignment = Alignment.Center) { + ChannelFabColumn(nav) + } + } + }, + second = { + Box(Modifier.fillMaxSize().padding(padding)) { + twoPaneNav.innerNav.value?.let { + if (it is Route.Room) { + ChatroomView( + room = it.toKey(), + accountViewModel = accountViewModel, + draftMessage = it.message, + replyToNote = it.replyId, + editFromDraft = it.draftId, + expiresDays = it.expiresDays, + nav = nav, + ) + } + + if (it is Route.PublicChatChannel) { + PublicChatChannelView( + channelId = it.id, + accountViewModel = accountViewModel, + nav = nav, + ) } } - }, - second = { - Box(Modifier.fillMaxSize().padding(padding)) { - twoPaneNav.innerNav.value?.let { - if (it is Route.Room) { - ChatroomView( - room = it.toKey(), - accountViewModel = accountViewModel, - draftMessage = it.message, - replyToNote = it.replyId, - editFromDraft = it.draftId, - expiresDays = it.expiresDays, - nav = nav, - ) - } - - if (it is Route.PublicChatChannel) { - PublicChatChannelView( - channelId = it.id, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } - } - }, - strategy = strategy, - displayFeatures = displayFeatures, - foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, - modifier = Modifier.fillMaxSize(), - ) - } + } + }, + strategy = strategy, + displayFeatures = displayFeatures, + foldAwareConfiguration = FoldAwareConfiguration.VerticalFoldsOnly, + modifier = Modifier.fillMaxSize(), + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index 598be6ef1a..d40dd9db6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -38,16 +38,13 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp 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.ui.layouts.LocalFeedSidePadding import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.layouts.NotificationPanelWidth import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -126,18 +123,15 @@ fun NotificationSidePanel( HorizontalDivider(thickness = DividerThickness) - // The panel is its own narrow pane; never apply the center pane's reading-width cap here. - CompositionLocalProvider(LocalFeedSidePadding provides 0.dp) { - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) - } + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt index f66a2c42a9..3e9b7261c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/layouts/PaddingMerge.kt @@ -49,15 +49,15 @@ val LocalDisappearingScaffoldPadding = compositionLocalOf { PaddingValues(0.dp) val LocalDisappearingBarState = compositionLocalOf { null } /** - * Extra start/end padding wide layouts ask feeds to apply so their content column stays at a - * readable width. The shell computes it as `(paneWidth - feedMaxWidth) / 2` and provides it - * around the center pane; feeds pick it up through [rememberFeedContentPadding], so the - * scroll surface stays full-pane-width (scrolling and pull-to-refresh keep working edge to - * edge) while items center themselves. Defaults to 0 (phones, and any host that doesn't cap). + * Extra start/end padding a host can ask feeds to apply so their content column stays at a + * readable width while the scroll surface stays full-pane (scrolling and pull-to-refresh + * keep working edge to edge). Feeds pick it up through [rememberFeedContentPadding]. + * Defaults to 0 everywhere. * - * Panes that manage their own width — side panels, the panes of a two-pane split — must - * override this back to 0 for their subtree: the shell's value was computed against the full - * center pane and would over-pad a narrower list. + * The Android shell does NOT provide this — it caps each NavHost destination's width + * instead (CappedScreenContent), which also constrains top bars and non-feed screens. + * The local remains for hosts that prefer padding-based capping (e.g. a desktop-style + * reading column where gutters should still scroll). */ val LocalFeedSidePadding = compositionLocalOf { 0.dp } From bced238db4e7bf1d8fbd504f56adbb5a88f8f835 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:46:27 +0000 Subject: [PATCH 165/206] fix: give the notification panel a Surface so its text follows the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel's Column sat in a bare Row with no Surface above it, so LocalContentColor fell back to Color.Black and the header label was invisible on the dark theme — the same trap DisappearingScaffold documents for its own root. The Surface provides the container color and onBackground content color for everything inside the panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../notifications/NotificationSidePanel.kt | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt index d40dd9db6c..737c9439c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationSidePanel.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -90,48 +91,54 @@ fun NotificationSidePanel( WatchAccountForNotifications(notifFeedContentState, accountViewModel) - Column( - modifier - .width(NotificationPanelWidth) - .fillMaxHeight() - .windowInsetsPadding( + // The Surface provides the Material container color + onBackground as LocalContentColor; + // in a bare Row the default text color falls back to Color.Black and is invisible on the + // dark theme (same reason DisappearingScaffold roots itself in a Surface). + Surface( + modifier = modifier.width(NotificationPanelWidth).fillMaxHeight(), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ) { + Column( + Modifier.windowInsetsPadding( WindowInsets.systemBars.only( WindowInsetsSides.Top + WindowInsetsSides.Bottom + WindowInsetsSides.End, ), ), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { nav.nav(Route.Notification()) } - .padding(horizontal = Size16dp, vertical = Size12dp), - verticalAlignment = Alignment.CenterVertically, ) { - Icon( - symbol = MaterialSymbols.Notifications, - contentDescription = null, - modifier = Size22Modifier, - tint = MaterialTheme.colorScheme.onBackground, - ) - Spacer(modifier = StdHorzSpacer) - Text( - text = stringRes(R.string.route_notifications), - style = MaterialTheme.typography.titleMedium, - ) - } + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.Notification()) } + .padding(horizontal = Size16dp, vertical = Size12dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Notifications, + contentDescription = null, + modifier = Size22Modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + Spacer(modifier = StdHorzSpacer) + Text( + text = stringRes(R.string.route_notifications), + style = MaterialTheme.typography.titleMedium, + ) + } - HorizontalDivider(thickness = DividerThickness) + HorizontalDivider(thickness = DividerThickness) - Box(Modifier.weight(1f).fillMaxWidth()) { - SingleNotificationsBody( - notifFeedContentState = notifFeedContentState, - notifPolls = accountViewModel.feedStates.notificationsOpenPolls, - scrollToEventId = null, - accountViewModel = accountViewModel, - nav = nav, - scrollStateKey = scrollStateKey, - ) + Box(Modifier.weight(1f).fillMaxWidth()) { + SingleNotificationsBody( + notifFeedContentState = notifFeedContentState, + notifPolls = accountViewModel.feedStates.notificationsOpenPolls, + scrollToEventId = null, + accountViewModel = accountViewModel, + nav = nav, + scrollStateKey = scrollStateKey, + ) + } } } } From cc03651460b50285d20bc3bea30b88708130d49e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:49:18 +0000 Subject: [PATCH 166/206] fix: pad custom top bars by systemBars so desktop caption bars are respected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search and Browser top bars are plain layouts (not Material3 TopAppBars) padded with statusBarsPadding only, and DisappearingScaffold used the same for its no-top-bar fallback. In a desktop-style window (Waydroid/DeX freeform) the window's caption/title bar is a separate inset that statusBars does not include — Material's own top bars pad by systemBars and were fine, but these three drew underneath the title bar. Pad by WindowInsets.systemBars top instead, which covers both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/layouts/DisappearingScaffold.kt | 8 ++++++-- .../ui/screen/loggedIn/browser/BrowserScreen.kt | 11 ++++++++--- .../ui/screen/loggedIn/search/SearchScreen.kt | 11 +++++++++-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt index 40b32b4327..a9c963f370 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingScaffold.kt @@ -24,10 +24,13 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -129,7 +132,8 @@ fun DisappearingScaffold( } val rootModifier = baseModifier - .let { if (topBar == null) it.statusBarsPadding() else it } + // systemBars (not just statusBars) so a desktop window's caption bar is respected too. + .let { if (topBar == null) it.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) else it } .let { if (bottomBar == null) it.navigationBarsPadding() else it } Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index a74ac3db4e..4e0f47f509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -28,12 +28,16 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -313,8 +317,9 @@ private fun OmniBar( Modifier .fillMaxWidth() // The omnibox is a plain Row in the topBar slot (not a Material3 TopAppBar), so it must - // apply the status-bar inset itself — otherwise it draws under the status bar. - .statusBarsPadding() + // apply the top system insets itself — systemBars rather than just statusBars, so the + // caption bar of a desktop window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) .padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index c3d39717bf..94823f094f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -26,13 +26,17 @@ 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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.selection.selectable @@ -197,7 +201,10 @@ private fun SearchBar( modifier = Modifier .background(MaterialTheme.colorScheme.surface) - .statusBarsPadding(), + // A custom bar (not a Material3 TopAppBar) must apply the top system insets + // itself. systemBars — not just statusBars — so the caption bar of a desktop + // window (Waydroid/DeX freeform) is respected too. + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)), ) { SearchTextField(searchBarViewModel, Modifier) // Inline Namecoin lookup feedback for the global search field. From adb26df838b46234328901b71a39873133006f74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 14:00:48 +0000 Subject: [PATCH 167/206] feat: tier-scaled navigation transitions for large screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-width slide-from-end pushes read as disconnected on large screens: the click comes from the docked drawer on the left and a whole 600dp+ column flies across the pane from the right. Keep one navigation grammar (drill-in from end, modal from bottom, tab switches fade) and scale the motion per tier: phones keep the existing full-width slides, large screens get shared-axis moves — a 1/10-pane nudge plus fade — and the screen behind a push fades with its slight scale so both aren't visible mid-transition. Transition specs run outside composition and can't read LocalScreenLayout, so AppNavigation mirrors the tier into NavTransitionTier for the spec lambdas, all of which live in NavigationEffects' shared builders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RHSoAVvYioihaJeSumX8J7 --- .../amethyst/ui/navigation/AppNavigation.kt | 5 ++ .../ui/navigation/NavigationEffects.kt | 59 ++++++++++++++++--- 2 files changed, 56 insertions(+), 8 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 087ebdb43c..149d0ff90f 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -294,6 +295,10 @@ fun AppNavigation( val screenLayout = rememberScreenLayoutSpec() val tabReselectCoordinator = remember { TabReselectCoordinator() } + // Mirror the tier for the nav-transition specs, which run outside composition and so + // can't read LocalScreenLayout (see NavTransitionTier). + SideEffect { NavTransitionTier.isLargeScreen = screenLayout.isLargeScreen } + CompositionLocalProvider( LocalScreenLayout provides screenLayout, LocalTabReselectCoordinator provides tabReselectCoordinator, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt index 8b16865a31..3e01d5bb98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/NavigationEffects.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.navigation import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInHorizontally @@ -45,6 +47,21 @@ const val BOTTOM_NAV_ROOT_KEY = "bottomNavRoot" fun NavBackStackEntry.isBottomNavRoot(): Boolean = savedStateHandle.get(BOTTOM_NAV_ROOT_KEY) == true +/** + * The shell's current layout tier, mirrored for the transition specs below. Transition + * lambdas run when a navigation starts — outside composition — so they can't read + * LocalScreenLayout; AppNavigation mirrors the spec here instead. + * + * One navigation grammar, tier-scaled motion: phones keep full-width slides (a pushed + * screen physically stacks on top), while large screens use short shared-axis moves — + * content there swaps inside a persistent shell, and a full-pane slide from the right + * reads as disconnected when the click came from the docked drawer on the left. + */ +object NavTransitionTier { + @Volatile + var isLargeScreen: Boolean = false +} + /** * Applies the wide-pane reading-column cap ([CappedScreenContent]) to a destination unless * it opted out with `capWidth = false`. Every builder below routes through this, so all @@ -74,10 +91,10 @@ inline fun NavGraphBuilder.composableFromEnd( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { if (targetState.isBottomNavRoot()) null else slideInHorizontallyFromEnd }, - exitTransition = { if (targetState.isBottomNavRoot()) null else scaleOut }, - popEnterTransition = { if (initialState.isBottomNavRoot()) null else scaleIn }, - popExitTransition = { if (initialState.isBottomNavRoot()) null else slideOutHorizontallyToEnd }, + enterTransition = { if (targetState.isBottomNavRoot()) null else enterFromEnd() }, + exitTransition = { if (targetState.isBottomNavRoot()) null else exitBehind() }, + popEnterTransition = { if (initialState.isBottomNavRoot()) null else popEnterFromBehind() }, + popExitTransition = { if (initialState.isBottomNavRoot()) null else popExitToEnd() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -98,10 +115,10 @@ inline fun NavGraphBuilder.composableFromBottom( noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit, ) { composable( - enterTransition = { slideInVerticallyFromBottom }, - exitTransition = { scaleOut }, - popEnterTransition = { scaleIn }, - popExitTransition = { slideOutVerticallyToBottom }, + enterTransition = { enterFromBottom() }, + exitTransition = { exitBehind() }, + popEnterTransition = { popEnterFromBehind() }, + popExitTransition = { popExitToBottom() }, content = { entry -> MaybeCappedScreen(capWidth) { content(entry) } }, @@ -134,3 +151,29 @@ val slideOutHorizontallyToEnd = slideOutHorizontally(animationSpec = tween(), ta val scaleIn = scaleIn(animationSpec = tween(), initialScale = 0.9f) val scaleOut = scaleOut(animationSpec = tween(), targetScale = 0.9f) + +/** Fraction of the pane a shared-axis move travels on large screens — a nudge, not a fly-in. */ +private const val SHARED_AXIS_FRACTION = 10 + +val sharedAxisEnterFromEnd = slideInHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToEnd = slideOutHorizontally(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) +val sharedAxisEnterFromBottom = slideInVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeIn(animationSpec = tween()) +val sharedAxisExitToBottom = slideOutVertically(animationSpec = tween()) { it / SHARED_AXIS_FRACTION } + fadeOut(animationSpec = tween()) + +// The outgoing/incoming screen *behind* a push: on phones the pushed screen covers it, so a +// slight scale is enough; on large screens the incoming screen fades, so the one behind must +// fade too or both stay visible mid-transition. +val fadeScaleOut = scaleOut + fadeOut(animationSpec = tween()) +val fadeScaleIn = scaleIn + fadeIn(animationSpec = tween()) + +fun enterFromEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromEnd else slideInHorizontallyFromEnd + +fun popExitToEnd() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToEnd else slideOutHorizontallyToEnd + +fun enterFromBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisEnterFromBottom else slideInVerticallyFromBottom + +fun popExitToBottom() = if (NavTransitionTier.isLargeScreen) sharedAxisExitToBottom else slideOutVerticallyToBottom + +fun exitBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleOut else scaleOut + +fun popEnterFromBehind() = if (NavTransitionTier.isLargeScreen) fadeScaleIn else scaleIn From 5e41a266787ca6d33473f0a8258bdf9e88713aaf Mon Sep 17 00:00:00 2001 From: vitorpamplona <532031+vitorpamplona@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:22:01 +0000 Subject: [PATCH 168/206] chore: sync Crowdin translations and seed translator npub placeholders --- amethyst/src/main/res/values-cs/strings.xml | 1 - amethyst/src/main/res/values-de-rDE/strings.xml | 1 - amethyst/src/main/res/values-es-rES/strings.xml | 1 - amethyst/src/main/res/values-es-rMX/strings.xml | 1 - amethyst/src/main/res/values-es-rUS/strings.xml | 1 - amethyst/src/main/res/values-fa-rIR/strings.xml | 1 - amethyst/src/main/res/values-fr-rFR/strings.xml | 1 - amethyst/src/main/res/values-hi-rIN/strings.xml | 1 - amethyst/src/main/res/values-hu-rHU/strings.xml | 1 - amethyst/src/main/res/values-nl-rNL/strings.xml | 1 - amethyst/src/main/res/values-pl-rPL/strings.xml | 1 - amethyst/src/main/res/values-pt-rBR/strings.xml | 1 - amethyst/src/main/res/values-sl-rSI/strings.xml | 1 - amethyst/src/main/res/values-sv-rSE/strings.xml | 1 - amethyst/src/main/res/values-th-rTH/strings.xml | 1 - amethyst/src/main/res/values-zh-rCN/strings.xml | 3 ++- amethyst/src/main/res/values-zh-rHK/strings.xml | 1 - amethyst/src/main/res/values-zh-rSG/strings.xml | 1 - amethyst/src/main/res/values-zh-rTW/strings.xml | 1 - 19 files changed, 2 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index 706a9fdc4b..d60ee8dc55 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -2797,7 +2797,6 @@ Zdroj: Servery: Otevřít - OTS: %1$s Důkaz časového razítka Existuje důkaz, že tento příspěvek byl podepsán někdy před %1$s. Důkaz byl označen v Bitcoin blockchainu v tomto datu a čase. Upravit článek diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index c8903d7063..be4bfcb024 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -2683,7 +2683,6 @@ Quelle: Server: Öffnen - OTS: %1$s Zeitstempel Beweis Es gibt einen Beweis, dass dieser Beitrag irgendwann vor %1$s signiert wurde. Der Beweis wurde zu diesem Datum und Uhrzeit in der Bitcoin-Blockchain gestempelt. Artikel bearbeiten diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index b8ce45f477..3b8df32c0f 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -2297,7 +2297,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index d57b90ddfe..2c9f2c92f3 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index d83d1cbde8..7835174c36 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -2288,7 +2288,6 @@ Fuente: Servidores: Abrir - OTS: %1$s Prueba de marca de tiempo Hay prueba de que esta publicación se firmó en algún momento antes de %1$s. La prueba se marcó en la cadena de bloques de Bitcoin en esa fecha y hora. Editar artículo diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 8ffe8921b4..201d7e725d 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -2253,7 +2253,6 @@ منبع: سرورها: باز کردن - OTS: %1$s اثبات مهر زمان مدرکی وجود دارد که ثابت کند این پست پیش از %1$s امضا شده است. این مدرک در بلاکچین بیتکوین در آن تاریخ و زمان ثبت شده است. ویرایش مقاله diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 89536d9bac..7e01e4ac0c 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -2511,7 +2511,6 @@ Source : Serveurs : Ouvrir - OTS: %1$s Preuve d\'horodatage Il y a une preuve que ce message a été signé avant %1$s. La preuve a été estampillée dans la blockchain Bitcoin à cette date et à cette heure. Modifier l\'article diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e096ff2974..4b2fd9b00d 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -2730,7 +2730,6 @@ स्रोत : सेवासंगणक : खोलें - ओ॰टी॰एस : %1$s समयांकन प्रमाण प्रमाण उपलब्ध है कि इस पत्र पर हस्ताक्षर किया गया %1$s के कुछ पहले। प्रमाण अंकित किया गया द्व्यंकरूप्य खण्डश्रृंखला में उस समय उस दिन पर। लेख सम्पादित करें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index c451b2e909..93a41749de 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -2731,7 +2731,6 @@ Forrás: Kiszolgálók: Megnyitás - OTS: %1$s Időbélyeg-igazolás Bizonyíték van arra, hogy ezt a bejegyzést valamikor %1$s előtt írták alá. A bizonyítékot ezen a napon és időpontban bélyegezték a Bitcoin blokkláncába. Cikk szerkesztése diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 88635bb0a0..bb876140cb 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -2566,7 +2566,6 @@ Bron: Servers: Openen - OTS: %1$s Bewijs van tijdstempel Er is cryptografisch bewijs dat dit bericht vóór %1$s is ondertekend. Dit bewijs is vastgelegd in de Bitcoin-blockchain. Artikel bewerken diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 9bc2d19653..5137fd21d6 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2798,7 +2798,6 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Źródło: Serwery: Otwórz - OTS: %1$s Potwierdzenie znacznika czasu Istnieje dowód na to, że ten post został podpisany przed %1$s. Dowód został opatrzony pieczęcią w łańcuchu bloków Bitcoin w tym dniu i czasie. Redaguj artykuł diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 8f23756f3a..fa993c1730 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -2680,7 +2680,6 @@ Fonte: Servidores: Abrir - OTS: %1$s Prova de Carimbo de data/hora Há prova de que esta postagem foi assinada antes de %1$s. A prova foi carimbada no blockchain do Bitcoin naquela data e hora. Editar artigo diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 7dd53b26e3..42ecf5cf1d 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2813,7 +2813,6 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Vir: Strežniki: Odpri - OTS: %1$s Dokaz časovnega žiga Obstaja dokaz, da je bil ta zapisek podpisan pred %1$s. Dokaz je bil ožigosan v Bitcoin verigi blokov na ta datum in čas. Uredi članek diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 6bbc9cff50..664c06a385 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -2688,7 +2688,6 @@ Källa: Servrar: Öppna - OTS: %1$s Tidsstämpel Bevis Det finns bevis på att detta inlägg signerades någon gång före %1$s. Beviset stämplades i Bitcoin-blockchainen vid det datumet och den tiden. Redigera artikel diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index dcb597e18d..49b6c3d5ac 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -2171,7 +2171,6 @@ แหล่งที่มา: เซิร์ฟเวอร์: เปิด - OTS: %1$s Timestamp ถูกยืนยัน มีหลักฐานว่าโพสต์นี้มีการลงนามก่อน %1$s หลักฐานถูกประทับตราใน Bitcoin blockchain ณ วันและเวลาดังกล่าว แก้ไขบทความ diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 3e56dab2f5..d7098aaa94 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -2697,7 +2697,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 @@ -3175,6 +3174,8 @@ 播放 自动 + 编辑昵称 + 仅对您可见 投射到设备 停止投影 diff --git a/amethyst/src/main/res/values-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index 21cb66c936..b93c51de96 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 65bb95dfd3..8436609a81 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -2232,7 +2232,6 @@ 源: 服务器: 打开 - OTS:%1$s OpenTimestamps 证明 %1$s之前的某个时候签署了此帖子的证明。此证明是在那个日期和时间在比特币区块链中盖章的。 编辑文章 diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index 98a024b0ee..9c9f7e0b50 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -2209,7 +2209,6 @@ 來源: 伺服器: 開啟 - OTS:%1$s 時間戳證明 有在 %1$s 之前的某個時間簽署了此帖子的證明。此證明是在那個日期和時間在比特幣區塊鏈中記錄的時間戳。 編輯文章 From e734d6400c245688cd0754ea7e474d14f4affd4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:44:56 +0000 Subject: [PATCH 169/206] 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 63f26b5cb4617423c1b547e110de7cf6403115f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 19:55:12 +0000 Subject: [PATCH 170/206] feat: fall back to the relay's NIP-11 icon for NIP-29 rooms without a picture In the Messages list, a NIP-29 group whose kind-39000 metadata has no (or a blank) picture now shows its host relay's NIP-11 icon instead of jumping straight to the robohash fallback. loadRelayInfo fetches the NIP-11 document on demand if it isn't cached yet, and the robohash still kicks in when the relay has no icon either. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6 --- .../loggedIn/chats/rooms/ChatroomHeaderCompose.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 52aec3f3c0..ad8946e39e 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 @@ -372,9 +372,20 @@ private fun RelayGroupRoomCompose( stringRes(R.string.relay_group_no_messages_yet) } + val groupPicture = channel.profilePicture()?.ifBlank { null } + val channelPicture = + if (groupPicture != null) { + groupPicture + } else { + // Missing/blank group picture: fall back to the host relay's NIP-11 icon + // (loadRelayInfo fetches the doc on a cache miss). + val relayInfo by loadRelayInfo(channel.groupId.relayUrl) + relayInfo.icon?.ifBlank { null } + } + ChannelName( channelIdHex = channel.groupId.id, - channelPicture = channel.profilePicture(), + channelPicture = channelPicture, channelTitle = { modifier -> Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { Text( From 5507b1debfd5fb79d9992aa972599a6ac456cb91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:06:20 +0000 Subject: [PATCH 171/206] feat: bigger relay icons and NIP-29 support warnings on Find Groups The relay rows on the Find Groups (browse) screen now use the 55dp LargeRelayIconModifier, matching the avatar size of the Messages list, instead of the 13dp default. When a relay's NIP-11 document resolves with a supported_nips list that lacks "29" and no self key, the row shows a 'May not support groups (NIP-29)' warning, and the relay's (empty) group directory screen says the relay doesn't advertise NIP-29 instead of the generic empty text. The check lives in looksLikeNonNip29Relay next to the other NIP-29 NIP-11 helpers; a null supported_nips (doc still loading or fetch failed) never warns. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RsPtTBsJ3sJkjodN1Knbx6 --- .../model/nip11RelayInfo/RelaySupportsNip.kt | 9 +++++++ .../relayGroup/RelayGroupChannelListScreen.kt | 14 ++++++++-- .../relayGroup/RelayGroupServerList.kt | 27 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 ++ 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index 779efc2079..792daf74fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -43,6 +43,15 @@ fun relayAdvertisesNip( /** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") +/** + * Whether [relayInfo] affirmatively signals that its relay does NOT run NIP-29 groups: the doc + * resolved with an explicit `supported_nips` list that lacks "29" and no `self` key (the field + * NIP-29 relays publish so clients can verify their relay-signed group metadata — see + * [isRelaySignedRelayGroup]). A doc with a null `supported_nips` proves nothing (still loading, + * or the fetch failed), so it never triggers the warning. + */ +fun looksLikeNonNip29Relay(relayInfo: Nip11RelayInformation): Boolean = relayInfo.supported_nips?.none { it == "29" } == true && relayInfo.self == null + /** * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 6821e1ba79..6a1f8cb62c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChann import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -65,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelaySubscription import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -139,11 +141,19 @@ fun RelayGroupChannelListScreen( ) { padding -> val myPubkey = accountViewModel.userProfile().pubkeyHex if (channels.isEmpty()) { + // An empty directory on a relay whose NIP-11 says it doesn't run NIP-29 is almost + // certainly the wrong relay, not a young one — say so instead of the generic empty text. + val notNip29 = looksLikeNonNip29Relay(relayInfo) Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( - text = stringRes(R.string.relay_group_channels_empty), + text = + if (notNip29) { + stringRes(R.string.relay_group_channels_not_nip29) + } else { + stringRes(R.string.relay_group_channels_empty) + }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (notNip29) MaterialTheme.colorScheme.warningColor else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(32.dp), ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt index e3068e3e06..53ebc9913c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupServerList.kt @@ -35,11 +35,16 @@ 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 com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo +import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier +import com.vitorpamplona.amethyst.ui.theme.warningColor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -54,6 +59,7 @@ fun RelayGroupServerRow( val host = relay?.displayUrl() ?: relayUrl val info = relay?.let { loadRelayInfo(it) } val name = info?.value?.name?.takeIf { it.isNotBlank() } ?: host + val missingNip29 = info?.value?.let { looksLikeNonNip29Relay(it) } == true Row( modifier = @@ -70,6 +76,7 @@ fun RelayGroupServerRow( loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), pingInMs = 0, + iconModifier = LargeRelayIconModifier, ) Column(Modifier.weight(1f)) { Text( @@ -87,6 +94,26 @@ fun RelayGroupServerRow( overflow = TextOverflow.Ellipsis, ) } + if (missingNip29) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + symbol = MaterialSymbols.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.warningColor, + modifier = Modifier.size(14.dp), + ) + Text( + text = stringRes(R.string.relay_group_relay_not_nip29), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.warningColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } Icon( symbol = MaterialSymbols.ChevronRight, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index fc352bc4ad..f38e025deb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2017,6 +2017,8 @@ Threads Open group No groups on this relay yet. + This relay does not advertise support for groups (NIP-29), so it may not host any. + May not support groups (NIP-29) Preparing invite… Private Invite-only From 64e0fa2a918a50e3f1c0a0c909f7d20226169cfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:08:20 +0000 Subject: [PATCH 172/206] 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 173/206] 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 174/206] 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 58e018c87777413c1c4e4d44ebd2352f126a0de8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:29:09 +0000 Subject: [PATCH 175/206] feat(cli): graperank scores always persist locally; add publish + rank verbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `amy graperank` / `graperank score` run now reconciles its result into the local event store as NIP-85 kind:30382 cards signed by the per-observer service key (cutoff --min-rank, default 2): changed ranks are re-signed, unchanged ones skipped (no event-id churn), dropped targets retracted with a kind:5 the store applies on insert. The store is the source of truth, so the score of a run is durable and reusable instead of ephemeral stdout. New verbs complete the crawl -> score -> publish pipeline: - `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only: one NIP-77 up-only reconcile per operator relay over the provider key's kind:30382 + kind:5, converging the relay to the local set (deletions propagate, lost cards restored, full-set publish fallback for relays without negentropy); also refreshes the observer's kind:10040 pointer. - `graperank rank USER [--provider P] [--refresh]` — the consumer side: newest card per provider from the local store, with a relay drain on miss. GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is replaced by reconcileLocal (sign+insert+retract against the store) and syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a commonTest covering upsert/skip/retract and re-carding a retracted target. BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay` and `--bench-sign` are removed from the score command. Its JSON drops published/publish_rejected/deleted/delete_rejected/skipped_unchanged/ publish_truncated/published_kind/published_to/publish_error/observer_10040/ bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/ cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to the new `graperank publish` verb. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 43 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 29 +- .../amethyst/cli/commands/GrapeRankCommand.kt | 377 ++++++++++++------ .../graperank/GrapeRankPublisher.kt | 315 ++++++++++----- .../graperank/GrapeRankPublisherTest.kt | 128 ++++++ 6 files changed, 645 insertions(+), 249 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt diff --git a/cli/README.md b/cli/README.md index 928009c996..bf393da508 100644 --- a/cli/README.md +++ b/cli/README.md @@ -389,14 +389,16 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | | `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | -| `amy graperank [OBSERVER] [--offline] [--publish] [--min-rank N] [--publish-relay URL]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. With `--publish`, reconciles NIP-85 kind:30382 cards signed by a per-observer **service key**: publishes changed/new ranks (cutoff `--min-rank`, default 2), skips unchanged, and **retracts** (kind:5) any card whose target left the graph or fell below the cutoff. | -| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where cards + retractions publish; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | +| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `graperank score` is the local-only variant (same as `--offline`). | +| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | +| `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | +| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | -#### Publishing GrapeRank scores (NIP-85) +#### GrapeRank scores are persisted locally, then published (NIP-85) -Ranks are published as kind:30382 cards, but **not** under your account key. A +Ranks are signed as kind:30382 cards, but **not** under your account key. A machine holds one **operator master** seed (`~/.amy/operator/`, stored via the same `--secret-backend` as accounts, independent of any account). From it a distinct, deterministic **service key** is derived per observer: @@ -406,23 +408,32 @@ serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerH ``` Because kind:30382 is addressable (`pubkey + d-tag`), the stable per-observer key -means re-publishing **replaces** a target's card instead of orphaning it — and -losing everything but the master seed still re-derives every key. Set up once and -publish: +means re-signing **replaces** a target's card instead of orphaning it — and +losing everything but the master seed still re-derives every key. + +**Every score run persists its cards.** After scoring, Amy reconciles the result +into the local store: new or changed ranks (≥ `--min-rank`, default 2) are +signed; unchanged ranks are skipped (no new event id); and any card whose target +dropped out of the graph or fell below the cutoff is **retracted** with a kind:5 +(the store applies it; the tombstone is kept). The local store is the source of +truth — `graperank rank USER` reads it offline, and `graperank publish` mirrors +it out: ```bash amy graperank operator relay wss://relay.example.com # where all cards live -amy graperank --publish # sign with the observer's service key +amy graperank # crawl + score + persist cards locally +amy graperank publish # make the operator relay match the local set ``` -Each publish **reconciles** against what the service key already published: new or -changed ranks (≥ `--min-rank`, default 2) are signed and sent; unchanged ranks are -skipped (no new event id); and any card whose target dropped out of the graph or -fell below the cutoff is **retracted** with a kind:5. When the observer is your -own account (we hold the key), Amy also writes their kind:10040 pointing -`30382:rank → serviceKey @ operator relay` to their outbox, so clients can find -the cards. For a third-party observer, `graperank operator providers` prints the -`observer → service-pubkey` mapping to wire their kind:10040 out-of-band. +`publish` never re-scores or re-signs: it runs one NIP-77 up-only reconcile per +relay over the service key's kind:30382 + kind:5, so the relay converges to the +local card set (deletions included, lost cards restored); a relay that can't +negentropy-reconcile gets the full set published event-by-event instead. When +the observer is your own account (we hold the key), `publish` also writes their +kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox, +so clients can find the cards. For a third-party observer, `graperank operator +providers` prints the `observer → service-pubkey` mapping to wire their +kind:10040 out-of-band. ### Direct messages (NIP-17) diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index d0fc574d7c..6144320f7b 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. | | NIP-65 outbox model queries | 🆕 | | -| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | 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 f8c304711d..d7410753bc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -611,17 +611,30 @@ private fun printUsage() { | [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox | [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every | [--offline] [--timeout SECS] discovered user has been checked (no user cap; - | [--diagnose] --max-hops bounds follow distance, e.g. 8; + | [--diagnose] [--min-rank N] --max-hops bounds follow distance, e.g. 8; | --diagnose dumps per-relay telemetry: outcome | mix, yield, latency, and a LIVE/DEAD + limits | classification table of every relay contacted). - | [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default: - | [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local - | store only. --publish reconciles NIP-85 kind:30382 - | cards signed by a per-observer service key: sends - | new/changed ranks >= --min-rank (default 2), skips - | unchanged, and retracts (kind:5) any card whose - | target left the graph or fell below the cutoff. + | OBSERVER: npub|nprofile|hex|name@domain (default: + | active account). --offline scores from the local + | store only. EVERY score run persists its result + | locally as NIP-85 kind:30382 cards signed by a + | per-observer service key (ranks >= --min-rank, + | default 2): changed ranks re-signed, unchanged + | skipped, dropped targets retracted (kind:5). + | `graperank publish` pushes that set to relays. + | graperank score [OBSERVER] local only: build the graph from the store and + | score (same as bare --offline; same flags). Fast + | and param-tunable without re-crawling. + | graperank publish [OBSERVER] transport only: make the operator relay(s) match + | [--relay URL[,URL…]] [--timeout SECS] the local card set via one NIP-77 up-only + | [--relay-concurrency N] reconcile per relay (nothing re-scored or + | re-signed; full-set publish fallback when a relay + | can't reconcile). Also refreshes the observer's + | kind:10040 pointer when we hold their key. + | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank + | [--refresh] [--timeout SECS] per provider, local store first, --refresh drains + | the operator/declared/bootstrap relays. | graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/ | [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring. | [--no-preconnect] Pre-connects every known-live relay in one parallel diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 243b88264d..124756afae 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -35,11 +35,9 @@ import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder 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.nip01Core.crypto.KeyPair 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.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent @@ -53,13 +51,8 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent -import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import java.net.InetSocketAddress import java.net.Socket @@ -80,26 +73,33 @@ import kotlin.math.roundToInt * unreachable outbox is retried a few times), then runs the scoring engine in * `commons/wot`. * - * Prints a ranked list (text, or one JSON object under `--json`). With - * `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent` - * trusted assertions (one per scored user, `rank = round(score*100)`). - * - * The crawl and the computation are separable, because the crawl persists every - * event it fetches to the store and the score is a pure function over it: + * The pipeline is three separable stages, each with its own verb, and the local + * store is the source of truth between them (the crawl persists every event it + * fetches; the score is a pure function over the store; every score run persists + * its result as locally-signed NIP-85 kind:30382 cards): * - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's * kind 3/10000/1984/10002 into the local store (aliased as the former `sync`). * Idempotent and cumulative, so run it a few times to make sure everything is * loaded. Scores nothing. - * - `amy graperank score [OBSERVER]` — local only: build the graph from the store - * and score (same as bare `--offline`). Instant and param-tunable; repeat with - * different `--rigor`/`--attenuation`/cutoffs without re-crawling. + * - `amy graperank score [OBSERVER]` — local only: build the graph from the store, + * score (same as bare `--offline`), and ALWAYS reconcile the result into the + * store as kind:30382 [ContactCardEvent] cards signed by the observer's + * per-observer service key (`rank = round(score*100)`, cutoff `--min-rank`): + * changed ranks are re-signed, unchanged ones skipped, dropped targets + * retracted with a kind:5. That persisted card set is what `publish` and + * `rank` reuse — scores are never ephemeral. + * - `amy graperank publish [OBSERVER]` — transport only: make the operator + * relay(s) converge to the local card set via a NIP-77 up-only reconcile + * (nothing is re-signed or re-scored), and refresh the observer's kind:10040 + * pointer when we hold their key. * - `amy graperank probe` — the relay census: mass-connect every relay the store * knows and record live/dead + measured RTT into the reachability cache, so the * next crawl skips the dead and pre-connects the living in one parallel storm. * - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score. * - * Sub-verbs complete the NIP-85 provider experience — the discovery layer that - * lets clients find and consume those assertions: + * Sub-verbs complete the NIP-85 experience — discovery and consumption: + * - `amy graperank rank USER` — read the kind:30382 cards about USER (local + * store first, `--refresh` to drain providers' relays): the consumer side. * - `amy graperank register` — advertise a `30382:rank` provider in the * account's kind:10040 [TrustProviderListEvent] (defaults to self, so a * provider publishing ranks announces where to find them). @@ -204,6 +204,8 @@ object GrapeRankCommand { "probe" -> probe(dataDir, tail.drop(1).toTypedArray()) "update" -> update(dataDir, tail.drop(1).toTypedArray()) "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) + "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) + "rank" -> rank(dataDir, tail.drop(1).toTypedArray()) else -> run(dataDir, tail) } @@ -227,17 +229,11 @@ object GrapeRankCommand { // the result JSON, so keep local copies for that. val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000 val insertBatch = args.intFlag("insert-batch", 500) - val doPublish = args.bool("publish") - // Publish cutoff: only cards with rank >= this are published; existing - // cards for targets below it (or gone from the graph) are retracted. Rank - // is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail. + // Card cutoff: only scores with rank >= this get a local kind:30382 card; + // existing cards for targets below it (or gone from the graph) are + // retracted. Rank is round(score*100), so 2 drops the ~0.015-and-below + // barely-trusted tail. val minRank = args.intFlag("min-rank", 2) - val publishLimit = args.intFlag("publish-limit", 500) - val publishRelaysArg = args.flag("publish-relay") - // Benchmark: build + sign one kind:30382 card per scored user (rank >= - // --min-rank) with a throwaway key and time it, WITHOUT publishing. - // Measures the id-hash + Schnorr-sign cost of emitting the full card set. - val benchSign = args.bool("bench-sign") val params = GrapeRankParams( @@ -348,84 +344,43 @@ object GrapeRankCommand { }, ) - if (doPublish) { - // The cards for THIS observer are signed by a dedicated, stable - // per-observer service key derived from the machine's operator - // master (see OperatorKeys) — not the account key. Same key across - // runs means re-signing a card replaces the addressable prior one. - val opKeys = ctx.dataDir.operatorKeys() - val serviceKey = opKeys.serviceKey(observer) - val serviceSigner = NostrSignerInternal(serviceKey) - val providerPubkey = serviceKey.pubKey.toHexKey() - result["provider_pubkey"] = providerPubkey + // Every score run persists its result: the desired card set (every user + // at or above the rank cutoff) is reconciled into the LOCAL store as + // kind:30382 cards — signed by a dedicated, stable per-observer service + // key derived from the machine's operator master (see OperatorKeys), + // not the account key. Changed ranks are re-signed (the addressable + // card is replaced), unchanged ones skipped, dropped targets retracted + // with a kind:5. `graperank publish` and `graperank rank` reuse this + // set; no relay is touched here. + val opKeys = ctx.dataDir.operatorKeys() + val serviceKey = opKeys.serviceKey(observer) + val providerPubkey = serviceKey.pubKey.toHexKey() + val desiredCards = + rankedIds + .filter { rankOf(scores[it]) >= minRank } + .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - // Cards go to the operator's own relay(s), where the whole - // trusted-assertion set lives; --publish-relay overrides. - val relays = - publishRelaysArg - ?.split(",") - ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } - ?.toSet() - ?.takeIf { it.isNotEmpty() } - ?: opKeys.operatorRelays() + val cardsStart = System.nanoTime() + val local = + GrapeRankPublisher(ctx.store) { System.err.println(it) } + .reconcileLocal( + providerSigner = NostrSignerInternal(serviceKey), + providerPubkey = providerPubkey, + scored = desiredCards, + ) + val cardsMs = (System.nanoTime() - cardsStart) / 1_000_000 + System.err.println( + "[graperank] local cards: ${local.signed} signed, ${local.unchanged} unchanged, " + + "${local.retracted} retracted in $cardsMs ms — `amy graperank publish` pushes them to the operator relay", + ) - if (relays.isEmpty()) { - result["published"] = 0 - result["publish_error"] = "no operator relay configured — run `amy graperank operator relay ` or pass --publish-relay" - } else { - // The scorer's desired card set: every user at or above the rank - // cutoff, as (target, rank). GrapeRankPublisher reconciles this - // against what this provider key already published and upserts / - // retracts the difference. - val publishable = - rankedIds - .filter { rankOf(scores[it]) >= minRank } - .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - - val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) } - val pub = - publisher.reconcileAndPublish( - providerSigner = serviceSigner, - providerPubkey = providerPubkey, - scored = publishable, - relays = relays, - publishLimit = publishLimit, - ) - - result["skipped_unchanged"] = pub.skippedUnchanged - if (pub.truncated > 0) result["publish_truncated"] = pub.truncated - result["published"] = pub.published - result["publish_rejected"] = pub.publishRejected - result["deleted"] = pub.deleted - result["delete_rejected"] = pub.deleteRejected - result["published_kind"] = ContactCardEvent.KIND - result["published_to"] = relays.map { it.url } - - // Help the observer point clients at this provider: publish their - // kind:10040 (30382:rank -> providerPubkey @ operator relay) to - // their outbox — but only when we actually hold their key. - maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let { - result["observer_10040"] = it - } - } - } - - if (benchSign) { - // Throwaway key — these cards are for timing only and never leave - // the process, so no real identity signs them. - val tempSigner = NostrSignerInternal(KeyPair()) - val cards = - rankedIds - .filter { rankOf(scores[it]) >= minRank } - .map { graph.pubkeyOf(it) to rankOf(scores[it]) } - val signStart = System.nanoTime() - val signed = signCards(cards, tempSigner) - val signMs = (System.nanoTime() - signStart) / 1_000_000 - val perSec = if (signMs > 0) signed * 1000L / signMs else 0 - System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)") - result["bench_signed"] = signed - result["bench_sign_ms"] = signMs - } + result["provider_pubkey"] = providerPubkey + result["min_rank"] = minRank + result["cards_total"] = desiredCards.size + result["cards_signed"] = local.signed + result["cards_unchanged"] = local.unchanged + result["cards_retracted"] = local.retracted + result["cards_ms"] = cardsMs Output.emit(result) return 0 @@ -780,37 +735,201 @@ object GrapeRankCommand { } /** - * Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned - * out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed - * events are discarded — this only exists to time card generation. Returns - * the number signed. + * `amy graperank publish [OBSERVER] [--relay URL[,URL…]] [--relay-concurrency N] [--timeout SECS]` + * + * Transport only: make the operator relay(s) converge to the local card set + * that `graperank score` persisted for OBSERVER (default: the active account). + * One NIP-77 up-only reconcile per relay over the provider service key's + * kind:30382 cards + kind:5 retractions — nothing is re-scored or re-signed, + * and a card the relay lost is restored. A relay that can't reconcile gets the + * full local set blast-published instead. Also refreshes the observer's + * kind:10040 provider pointer when we hold their key. */ - private suspend fun signCards( - cards: List>, - signer: NostrSigner, + private suspend fun publish( + dataDir: DataDir, + rest: Array, ): Int { - if (cards.isEmpty()) return 0 - val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) - val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1) - return coroutineScope { - cards - .chunked(chunkSize) - .map { chunk -> - async(Dispatchers.Default) { - for ((target, rank) in chunk) { - ContactCardEvent.create( - targetUser = target, - signer = signer, - publicInitializer = { add(RankTag.assemble(rank)) }, + val args = Args(rest) + val observerArg = args.positionalOrNull(0) + val relayArg = args.flag("relay") + val relayConcurrency = args.intFlag("relay-concurrency", 4) + val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000 + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val opKeys = ctx.dataDir.operatorKeys() + val providerPubkey = opKeys.serviceKey(observer).pubKey.toHexKey() + + // Cards live on the operator's own relay(s); --relay overrides. + val relays = + relayArg + ?.split(",") + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + ?: opKeys.operatorRelays() + if (relays.isEmpty()) { + return Output.error("no_relays", "no operator relay configured — run `amy graperank operator relay ` or pass --relay") + } + + val publisher = GrapeRankPublisher(ctx.store) { System.err.println(it) } + val sync = + publisher.syncToRelays( + client = ctx.client, + providerPubkey = providerPubkey, + relays = relays, + relayConcurrency = relayConcurrency, + idleTimeoutMs = idleTimeoutMs, + ) + + if (sync.cards == 0 && sync.deletions == 0) { + Output.emit( + linkedMapOf( + "observer" to observer, + "provider_pubkey" to providerPubkey, + "cards" to 0, + "note" to "no local cards for this observer — run `amy graperank score` first", + ), + ) + return 0 + } + + // Help the observer point clients at this provider: publish their + // kind:10040 (30382:rank -> providerPubkey @ operator relay) to + // their outbox — but only when we actually hold their key. + val observer10040 = maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first()) + + Output.emit( + linkedMapOf( + "observer" to observer, + "provider_pubkey" to providerPubkey, + "cards" to sync.cards, + "deletions" to sync.deletions, + "relays" to sync.perRelay.size, + "relays_ok" to sync.perRelay.count { it.ok }, + "relays_failed" to sync.perRelay.count { !it.ok }, + "uploaded" to sync.perRelay.sumOf { it.uploaded }, + "fallback_published" to sync.perRelay.sumOf { it.fallbackPublished }, + "per_relay" to + sync.perRelay.map { + linkedMapOf( + "relay" to it.relay.url, + "uploaded" to it.uploaded, + "fallback_published" to it.fallbackPublished, + "fallback_rejected" to it.fallbackRejected, + "error" to it.error, ) - } - chunk.size - } - }.awaitAll() - .sum() + }, + "observer_10040" to observer10040, + ), + ) + return 0 } } + /** + * `amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]` + * + * The consumer side of NIP-85: read the kind:30382 cards about USER and print + * one rank per provider (newest card each). Cache-first — a `graperank score` + * run on this machine already left its cards in the store — falling back to a + * relay drain on a miss or with `--refresh` (sources: the operator relays, the + * relays declared in the account's kind:10040, and the bootstrap set). + * `--provider` narrows to one provider key. + */ + private suspend fun rank( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = + args.positionalOrNull(0) + ?: return Output.error("bad_args", "usage: amy graperank rank USER [--provider PUBKEY] [--refresh] [--timeout SECS]") + val providerArg = args.flag("provider") + val refresh = args.bool("refresh") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val user = ctx.requireUserHex(userArg) + val provider = providerArg?.let { ctx.requireUserHex(it) } + val cardFilter = + Filter( + kinds = listOf(ContactCardEvent.KIND), + tags = mapOf("d" to listOf(user)), + authors = provider?.let { listOf(it) }, + ) + + suspend fun localCards(): List = ctx.store.query(cardFilter).filterIsInstance() + + var cards = localCards() + if (refresh || cards.isEmpty()) { + val relays = rankSourceRelays(ctx, provider) + if (relays.isNotEmpty()) { + ctx.drain(relays.associateWith { listOf(cardFilter.copy(limit = 50)) }, timeoutMs) + cards = localCards() + } + } + + // Newest card per provider key, strongest assertion first. + val newest = + cards + .groupBy { it.pubKey } + .mapNotNull { (_, list) -> list.maxByOrNull { it.createdAt } } + .sortedWith(compareByDescending { it.rank() ?: -1 }.thenByDescending { it.createdAt }) + + // A provider key this machine's operator master derived maps back to + // the observer whose subjective view the rank expresses. + val providerToObserver = + ctx.dataDir + .operatorKeys() + .providers() + .entries + .associate { (observer, rec) -> rec.providerPubKey to observer } + + Output.emit( + linkedMapOf( + "user" to user, + "found" to newest.isNotEmpty(), + "cards" to + newest.map { card -> + linkedMapOf( + "provider" to card.pubKey, + "rank" to card.rank(), + "observer" to providerToObserver[card.pubKey], + "created_at" to card.createdAt, + "event_id" to card.id, + ) + }, + ), + ) + return 0 + } + } + + /** + * Relays worth draining for someone's kind:30382 cards: the machine's own + * operator relay(s), every relay the account's kind:10040 declares for a + * 30382 service (narrowed to [provider] when given), and the bootstrap set. + */ + private suspend fun rankSourceRelays( + ctx: Context, + provider: HexKey?, + ): Set { + val declared = + if (!ctx.anonymous) { + providerListOf(ctx, ctx.identity.pubKeyHex) + ?.serviceProviders() + ?.filter { it.service.kind == ContactCardEvent.KIND && (provider == null || it.pubkey == provider) } + ?.map { it.relayUrl } + .orEmpty() + } else { + emptyList() + } + return ctx.dataDir.operatorKeys().operatorRelays() + declared + ctx.bootstrapRelays() + Constants.eventFinderRelays + } + /** * `amy graperank operator [status | relay … | providers]` * diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt index 1ccc26cea2..9ff48d83ef 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt @@ -22,6 +22,9 @@ package com.vitorpamplona.quartz.experimental.graperank import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyStoreSync +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -29,92 +32,165 @@ import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlin.coroutines.cancellation.CancellationException /** - * Publishes a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] - * trusted assertions (one `rank` card per scored user), reconciled against what - * this provider key has already published so a repeat run only writes what moved. + * Persists a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] + * trusted assertions in the local [IEventStore] — the durable, reusable form of a + * score run — and pushes that local card set out to the operator's relays on + * demand. The store is the source of truth; the two halves are separable: * - * Reconciliation, given the desired `(target, rank)` set the scorer produced: - * - **skip** a target whose stored card already carries the same rank string — - * re-signing an unchanged card would churn a new event id for no client benefit; - * - **upsert** a target whose rank changed (or that has no card yet), up to a - * publish limit; - * - **retract** every stored card whose target is no longer in the desired set - * (it fell below the caller's cutoff, or dropped out of the graph) with a NIP-09 - * kind:5 deletion, batched so the frame stays under the ~64KB event cap. - * - * Transport-agnostic like [GrapeRankCrawler]: it reads prior cards from an - * [IEventStore] and emits through an injected [publish] function (event + relays → - * per-relay ack), so the store/relay wiring stays in the application while the - * reconcile + card-construction logic is reusable (e.g. by the Android app). + * - [reconcileLocal] runs after every scoring pass. It diffs the desired + * `(target, rank)` set against the cards this provider key already holds in the + * store, signs + inserts only what moved (an unchanged rank is skipped so no new + * event id churns), and retracts every card whose target dropped out with a + * NIP-09 kind:5. The store applies kind:5 on insert, so a retracted card + * disappears locally while the deletion event remains as the durable tombstone + * to propagate. + * - [syncToRelays] is pure transport: a NIP-77 up-only reconcile of everything the + * provider key authored (kind:30382 cards + kind:5 retractions) against each + * relay, so the relay converges to the local set — nothing is re-signed, and a + * card the relay lost (or never had) is restored. A relay that can't reconcile + * gets the full local set blast-published instead (relays dedup by id). */ class GrapeRankPublisher( private val store: IEventStore, - private val publish: suspend (Event, Set) -> Map, + private val log: (String) -> Unit = {}, ) { - /** Outcome counts for one reconcile: what was written, retracted, and skipped. */ - class Result( - val published: Int, - val publishRejected: Int, - val deleted: Int, - val deleteRejected: Int, - val skippedUnchanged: Int, - /** Changed cards beyond [publishLimit] that were not upserted this run. */ - val truncated: Int, + /** Outcome of one [reconcileLocal]: what was signed, retracted, and left alone. */ + class LocalResult( + /** New or rank-changed cards signed and inserted into the store. */ + val signed: Int, + /** Stale cards retracted (kind:5) because their target left the desired set. */ + val retracted: Int, + /** Desired cards whose stored rank already matched — no new signature. */ + val unchanged: Int, ) /** * Reconcile the desired [scored] `(target, rank)` set (the caller has already - * applied any rank cutoff) against the cards [providerPubkey] previously - * published, then upsert the changes and retract the stale cards, all signed by - * [providerSigner]. At most [publishLimit] changed cards are upserted per run. + * applied any rank cutoff) into the local store, signed by [providerSigner]: + * upsert the changed cards, retract the stale ones, skip the unchanged rest. + * Every card and retraction is stamped [createdAt], so a replacement is + * strictly newer than what it displaces. */ - suspend fun reconcileAndPublish( + suspend fun reconcileLocal( providerSigner: NostrSigner, providerPubkey: HexKey, scored: List>, - relays: Set, - publishLimit: Int, - publishConcurrency: Int = PUBLISH_CONCURRENCY, - ): Result { - // Newest card per target this provider already published (read back from - // the store, which every published card was persisted to). + createdAt: Long = TimeUtils.now(), + ): LocalResult { val existing = existingCards(providerPubkey) - val publishableTargets = scored.mapTo(HashSet()) { it.first } + val desiredTargets = scored.mapTo(HashSet()) { it.first } - // Upsert publishable targets whose rank tag STRING would change (or that - // have no card yet). RankTag.assemble writes rank.toString(), so we diff - // that exact string — an unchanged score is skipped so clients only sync - // ranks that moved. + // Upsert targets whose rank tag STRING would change (or that have no card + // yet). RankTag.assemble writes rank.toString(), so we diff that exact + // string — an unchanged score never produces a new signature. val changed = scored.filter { (target, rank) -> existing[target]?.let(::rankTagValue) != rank.toString() } - val toUpsert = changed.take(publishLimit) - // Retract existing cards whose target is no longer publishable — it dropped - // out of the graph, or fell below the caller's cutoff. We won't leave a - // stale assertion standing. - val toDelete = existing.filterKeys { it !in publishableTargets }.values.toList() + // Retract cards whose target is no longer desired — it dropped out of the + // graph, or fell below the caller's cutoff. No stale assertion is left standing. + val toRetract = existing.filterKeys { it !in desiredTargets }.values.toList() - val (ok, rejected) = publishCards(providerSigner, toUpsert, relays, publishConcurrency) - val (deleted, deleteRejected) = publishDeletions(providerSigner, toDelete, relays) + val signed = signAndInsertCards(providerSigner, changed, createdAt) + val retracted = insertRetractions(providerSigner, toRetract, createdAt) - return Result( - published = ok, - publishRejected = rejected, - deleted = deleted, - deleteRejected = deleteRejected, - skippedUnchanged = scored.size - changed.size, - truncated = (changed.size - toUpsert.size).coerceAtLeast(0), + return LocalResult( + signed = signed, + retracted = retracted, + unchanged = scored.size - changed.size, ) } + /** Per-relay outcome of a [syncToRelays]. */ + class RelaySyncResult( + val relay: NormalizedRelayUrl, + /** Events the NIP-77 reconcile found missing on the relay and uploaded. */ + val uploaded: Int, + /** Events blast-published because the relay couldn't reconcile. */ + val fallbackPublished: Int, + val fallbackRejected: Int, + /** The negentropy failure that triggered the fallback, or null when it reconciled. */ + val error: String?, + ) { + /** True when the relay now converged to the local set by either path. */ + val ok: Boolean get() = error == null || (fallbackPublished > 0 && fallbackRejected == 0) + } + + /** Aggregate outcome of a [syncToRelays]. */ + class SyncResult( + /** kind:30382 cards this provider key holds locally (the set being mirrored). */ + val cards: Int, + /** kind:5 retraction events this provider key holds locally. */ + val deletions: Int, + val perRelay: List, + ) + /** - * The newest kind:30382 card [providerPubkey] published per target, read from - * the store (every card [publish] sends is persisted first, so on repeat runs - * this reflects what is already out there). + * Make every relay in [relays] converge to the local card set of + * [providerPubkey]: one NIP-77 up-only reconcile per relay over the provider's + * kind:30382 + kind:5 (nothing is downloaded — the store is the source of + * truth), falling back to blast-publishing the full local set when a relay + * can't reconcile. Best-effort per relay; a failure never aborts the others. + */ + suspend fun syncToRelays( + client: INostrClient, + providerPubkey: HexKey, + relays: Set, + relayConcurrency: Int = 4, + idleTimeoutMs: Long = 30_000L, + publishTimeoutSecs: Long = 15, + ): SyncResult { + val cards = store.query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey))) + val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(providerPubkey))) + + val filter = Filter(kinds = listOf(ContactCardEvent.KIND, DeletionEvent.KIND), authors = listOf(providerPubkey)) + val groups = + NegentropyStoreSync( + client = client, + store = store, + config = + NegentropyStoreSync.Config( + // Up-only: the relay must converge to the store, never the + // reverse — pulling a stale card back down would resurrect a + // locally-retracted assertion. The kind:5s ride the same + // filter, so deletions propagate as ordinary uploads. + down = false, + up = true, + syncDeletions = false, + // The paged fallback DOWNLOADS the filter — wrong direction + // for a push. Failed groups get [blastPublish] instead. + pageFallback = false, + concurrency = relayConcurrency, + idleTimeoutMs = idleTimeoutMs, + publishTimeoutSecs = publishTimeoutSecs, + ), + log = log, + ).sync(relays.associateWith { listOf(filter) }) + + val perRelay = + groups.map { g -> + if (g.error == null) { + RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = 0, fallbackRejected = 0, error = null) + } else { + log("[graperank] ${g.relay.url}: negentropy failed (${g.error}) — publishing the full local set instead") + val (ok, rejected) = blastPublish(client, cards + deletions, g.relay, publishTimeoutSecs) + RelaySyncResult(g.relay, uploaded = g.uploaded, fallbackPublished = ok, fallbackRejected = rejected, error = g.error) + } + } + + return SyncResult(cards = cards.size, deletions = deletions.size, perRelay = perRelay) + } + + /** + * The newest kind:30382 card [providerPubkey] holds per target in the local + * store. Locally-retracted cards never show up — inserting their kind:5 + * removed them — so a target can be re-carded later without fighting a ghost. */ private suspend fun existingCards(providerPubkey: HexKey): Map = store @@ -129,7 +205,7 @@ class GrapeRankPublisher( /** * The raw `rank` tag value string on a card — exactly what a client diffs, so an * unchanged score never produces a new signature. Our cards carry only a `rank` - * tag (plus the d-tag target), so this one value decides whether a re-publish + * tag (plus the d-tag target), so this one value decides whether a re-sign * would differ. */ private fun rankTagValue(card: ContactCardEvent): String? = @@ -137,63 +213,112 @@ class GrapeRankPublisher( if (tag.size > 1 && tag[0] == RankTag.TAG_NAME) tag[1] else null } - /** Build + publish one kind:30382 card per (target, rank), bounded-concurrently. */ - private suspend fun publishCards( + /** + * Build + sign one kind:30382 card per (target, rank) — fanned out on + * [Dispatchers.Default], since id-hash + Schnorr sign is CPU-bound — and insert + * each into the store. Batched so a whole-network card set never materializes + * in memory at once. Returns how many the store accepted. + */ + private suspend fun signAndInsertCards( signer: NostrSigner, cards: List>, - relays: Set, - concurrency: Int, - ): Pair { - var published = 0 - var rejected = 0 - for (batch in cards.chunked(concurrency)) { - val acks = + createdAt: Long, + ): Int { + if (cards.isEmpty()) return 0 + var inserted = 0 + for (batch in cards.chunked(SIGN_BATCH)) { + val signedBatch = coroutineScope { batch - .map { (pubkey, rank) -> - async { - val card = - ContactCardEvent.create( - targetUser = pubkey, - signer = signer, - publicInitializer = { add(RankTag.assemble(rank)) }, - ) - publish(card, relays) + .map { (target, rank) -> + async(Dispatchers.Default) { + ContactCardEvent.create( + targetUser = target, + signer = signer, + createdAt = createdAt, + publicInitializer = { add(RankTag.assemble(rank)) }, + ) } }.awaitAll() } - for (ack in acks) { - if (ack.values.any { it }) published++ else rejected++ + for (card in signedBatch) { + if (insertQuietly(card)) inserted++ } } - return published to rejected + return inserted } /** * Retract stale cards with NIP-09 kind:5 deletions signed by [signer] (the same - * key that signed the cards). Batches [DELETE_PER_EVENT] addressable coordinates - * per deletion so the kind:5 frame stays under the ~64KB event cap; each carries - * the card's `a` tag (30382:provider:target), so re-publishing a newer version - * later isn't blocked. Returns (deleted, rejected) card counts. + * key that signed the cards), inserted into the store — which applies them, so + * the retracted cards vanish locally and only the tombstone remains to sync. + * Batches [DELETE_PER_EVENT] addressable coordinates per deletion so the kind:5 + * frame stays under the ~64KB event cap; each carries the card's `a` tag + * (30382:provider:target), so re-publishing a newer version later isn't blocked. + * Returns how many cards were retracted. */ - private suspend fun publishDeletions( + private suspend fun insertRetractions( signer: NostrSigner, cards: List, - relays: Set, - ): Pair { - if (cards.isEmpty()) return 0 to 0 - var deleted = 0 - var rejected = 0 + createdAt: Long, + ): Int { + if (cards.isEmpty()) return 0 + var retracted = 0 for (chunk in cards.chunked(DELETE_PER_EVENT)) { - val event = signer.sign(DeletionEvent.build(chunk)) - val ack = publish(event, relays) - if (ack.values.any { it }) deleted += chunk.size else rejected += chunk.size + val deletion = signer.sign(DeletionEvent.build(chunk, createdAt)) + if (insertQuietly(deletion)) retracted += chunk.size } - return deleted to rejected + return retracted + } + + /** + * Insert without letting a single-row failure abort the whole reconcile. The + * one expected rejection is the store's own deletion guard (a re-carded target + * whose kind:5 landed in the same second); anything else is logged. Returns + * whether the store accepted the event. + */ + private suspend fun insertQuietly(event: Event): Boolean = + try { + store.insert(event) + true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("[graperank] store rejected ${event.kind}/${event.id.take(8)}: ${e.message}") + false + } + + /** + * Fallback for a relay without working NIP-77: publish every event of the local + * set individually (the relay dedups by id), bounded-concurrently. Returns + * (accepted, rejected) event counts. + */ + private suspend fun blastPublish( + client: INostrClient, + events: List, + relay: NormalizedRelayUrl, + publishTimeoutSecs: Long, + ): Pair { + var ok = 0 + var rejected = 0 + val relaySet = setOf(relay) + for (batch in events.chunked(PUBLISH_CONCURRENCY)) { + val acks = + coroutineScope { + batch.map { event -> async { client.publishAndConfirmDetailed(event, relaySet, publishTimeoutSecs) } }.awaitAll() + } + for (ack in acks) { + if (ack.values.any { it }) ok++ else rejected++ + } + } + return ok to rejected } companion object { - /** Concurrent card publishes when upserting. */ + /** Cards signed per batch — bounds live event objects, not parallelism. */ + const val SIGN_BATCH = 1024 + + /** Concurrent per-event publishes in the [blastPublish] fallback. */ const val PUBLISH_CONCURRENCY = 16 /** diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt new file mode 100644 index 0000000000..cbd3fbb6de --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisherTest.kt @@ -0,0 +1,128 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * [GrapeRankPublisher.reconcileLocal] is what makes a score run durable: the + * local store must end up holding exactly the desired card set — changed ranks + * re-signed, unchanged ranks untouched (no event-id churn), dropped targets + * retracted via kind:5 (which the store applies on insert) with the tombstone + * kept for a later relay sync. + */ +class GrapeRankPublisherTest { + private fun hexKey(n: Int): String = n.toString(16).padStart(64, '0') + + private suspend fun cardsByTarget( + store: IEventStore, + provider: String, + ): Map = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .filterIsInstance() + .associate { it.aboutUser() to it.rank() } + + @Test + fun reconcileLocalUpsertsSkipsAndRetracts() = + runBlocking { + val store = EventStore(null) + val signer = NostrSignerInternal(KeyPair()) + val provider = signer.pubKey + val publisher = GrapeRankPublisher(store) + + val a = hexKey(0xA) + val b = hexKey(0xB) + val c = hexKey(0xC) + + // First run: every card is new. + val r1 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 1_000L) + assertEquals(2, r1.signed) + assertEquals(0, r1.unchanged) + assertEquals(0, r1.retracted) + assertEquals(mapOf(a to 50, b to 10), cardsByTarget(store, provider)) + val firstIds = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .map { it.id } + .toSet() + + // Same ranks again: nothing is re-signed, no event id churns. + val r2 = publisher.reconcileLocal(signer, provider, listOf(a to 50, b to 10), createdAt = 2_000L) + assertEquals(0, r2.signed) + assertEquals(2, r2.unchanged) + assertEquals(0, r2.retracted) + val secondIds = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(provider))) + .map { it.id } + .toSet() + assertEquals(firstIds, secondIds) + + // a's rank moved, b dropped out, c is new: a + c signed, b retracted. + val r3 = publisher.reconcileLocal(signer, provider, listOf(a to 60, c to 5), createdAt = 3_000L) + assertEquals(2, r3.signed) + assertEquals(0, r3.unchanged) + assertEquals(1, r3.retracted) + + // The store converged to exactly the desired set — b's card is gone… + assertEquals(mapOf(a to 60, c to 5), cardsByTarget(store, provider)) + + // …but its kind:5 tombstone remains, ready to sync to relays. + val deletions = store.query(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(provider))) + assertEquals(1, deletions.size) + + store.close() + } + + @Test + fun retractedTargetCanBeReCardedLater() = + runBlocking { + val store = EventStore(null) + val signer = NostrSignerInternal(KeyPair()) + val provider = signer.pubKey + val publisher = GrapeRankPublisher(store) + + val a = hexKey(0xA) + + publisher.reconcileLocal(signer, provider, listOf(a to 40), createdAt = 1_000L) + publisher.reconcileLocal(signer, provider, emptyList(), createdAt = 2_000L) + assertEquals(emptyMap(), cardsByTarget(store, provider)) + + // A NEWER card outranks the older kind:5 (NIP-09 deletions only cover + // versions up to their created_at), so the target comes back cleanly. + val r = publisher.reconcileLocal(signer, provider, listOf(a to 45), createdAt = 3_000L) + assertEquals(1, r.signed) + assertEquals(mapOf(a to 45), cardsByTarget(store, provider)) + + store.close() + } +} From a81561424724b7146345e39b397f5dbc1beeb790 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:42:17 +0000 Subject: [PATCH 176/206] refactor(cli): move the relay census to `amy relay probe` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The census probes the whole known relay universe and feeds the shared NIP-66 reachability cache (kind:30166) that every reachability-aware command reads — it was never graperank-specific, so it moves from GrapeRankCommand to RelayCommands as `amy relay probe`. `amy graperank probe` stays as an alias, and flags, JSON output, and behaviour are unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 11 ++- .../amethyst/cli/commands/GrapeRankCommand.kt | 89 ++----------------- .../amethyst/cli/commands/RelayCommands.kt | 88 +++++++++++++++++- 4 files changed, 101 insertions(+), 88 deletions(-) diff --git a/cli/README.md b/cli/README.md index bf393da508..91446e2e74 100644 --- a/cli/README.md +++ b/cli/README.md @@ -553,6 +553,7 @@ the last facet removes R entirely. | `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). | | `amy relay list` | Print every configured relay bucket. | | `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | +| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`update`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | ### Local store maintenance 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 d7410753bc..f4b2d7a5ef 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -484,6 +484,11 @@ private fun printUsage() { | relay list print every configured relay bucket | relay publish-lists broadcast every configured relay list | relay info URL fetch + print a relay's NIP-11 info document + | relay probe [--timeout SECS] relay census: mass-connect every relay the store + | [--concurrency N] knows and record live/dead + measured rtt-open + | into the reachability cache (NIP-66 kind:30166), + | so reachability-aware commands (graperank crawl/ + | update) skip dead relays and wait once | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) | @@ -639,10 +644,8 @@ private fun printUsage() { | [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring. | [--no-preconnect] Pre-connects every known-live relay in one parallel | storm (seeded from the reachability cache). - | graperank probe [--timeout SECS] relay census: mass-connect every relay the store - | [--concurrency N] knows and record live/dead + measured rtt-open into - | the reachability cache (NIP-66 kind:30166), so the - | next crawl skips dead relays and waits once. + | graperank probe alias for `relay probe` (the census moved there — + | it feeds the shared NIP-66 reachability cache). | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 124756afae..ac836681fb 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -44,7 +44,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent -import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes @@ -92,9 +91,10 @@ import kotlin.math.roundToInt * relay(s) converge to the local card set via a NIP-77 up-only reconcile * (nothing is re-signed or re-scored), and refresh the observer's kind:10040 * pointer when we hold their key. - * - `amy graperank probe` — the relay census: mass-connect every relay the store + * - `amy relay probe` — the relay census: mass-connect every relay the store * knows and record live/dead + measured RTT into the reachability cache, so the * next crawl skips the dead and pre-connects the living in one parallel storm. + * Lives in [RelayCommands] (`graperank probe` is kept as an alias). * - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score. * * Sub-verbs complete the NIP-85 experience — discovery and consumption: @@ -201,7 +201,10 @@ object GrapeRankCommand { // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is // canonical (disambiguates from negentropy `amy sync` / `graperank update`). "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) - "probe" -> probe(dataDir, tail.drop(1).toTypedArray()) + // The relay census outgrew graperank (it feeds the shared NIP-66 + // reachability cache every command reads) and moved to `amy relay + // probe`; this alias keeps the old spelling working. + "probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) "update" -> update(dataDir, tail.drop(1).toTypedArray()) "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) @@ -540,86 +543,6 @@ object GrapeRankCommand { return 0 } - /** - * `amy graperank probe [--timeout SECS] [--concurrency N]` — - * the relay census. Mass-connects the ENTIRE relay universe the local store knows - * (every relay advertised in any stored kind:10002, deduped per host, plus - * everything already in the reachability cache) in parallel waves with a no-op - * REQ, so the "is this relay alive, and how slow?" wait is paid once, up front, - * concurrently — then records per-relay verdicts with real measured `rtt-open` - * into the NIP-66 reachability cache (kind:30166). - * - * The next `graperank crawl` reads that cache to (a) skip the dead set without - * dialing it and (b) pre-connect the live set in one storm — separating "working - * but slow" (kept; the crawler's patient park path waits for them) from "not - * working" (skipped entirely). Typical flow the first time: - * `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `graperank - * probe` → full `graperank crawl`. - */ - private suspend fun probe( - dataDir: DataDir, - rest: Array, - ): Int { - val args = Args(rest) - val timeoutMs = args.longFlag("timeout", 15L) * 1000 - val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap) - - Context.openOrAnonymous(dataDir).use { ctx -> - ctx.prepare() - val cached = ctx.reachability.snapshot() - val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead - if (universe.isEmpty()) { - Output.emit( - linkedMapOf( - "probed" to 0, - "note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists", - ), - ) - return 0 - } - - System.err.println( - "[relay-probe] probing ${universe.size} relays in waves of $waveSize " + - "(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})", - ) - val result = - RelayProber(ctx.client) { System.err.println(it) } - .probe(universe, timeoutMs, waveSize) - - ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays()) - - val rtts = - result.reachable - .map { it.rttOpenMs } - .filter { it >= 0 } - .sorted() - - fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100] - val slowest = - result.reachable - .filter { it.rttOpenMs >= 0 } - .sortedByDescending { it.rttOpenMs } - .take(10) - .map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) } - val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true } - - Output.emit( - linkedMapOf( - "probed" to result.verdicts.size, - "reachable" to result.reachable.size, - "dead" to result.dead.size, - "closed_by_policy" to authWalled, - "elapsed_ms" to result.elapsedMs, - "rtt_open_p50_ms" to pct(50), - "rtt_open_p90_ms" to pct(90), - "rtt_open_p99_ms" to pct(99), - "slowest" to slowest, - ), - ) - } - return 0 - } - /** * `amy graperank update [flags]` — refresh every locally-known author's WoT * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 74f48c1506..33207c6790 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber import okhttp3.OkHttpClient import okhttp3.Request @@ -86,7 +87,7 @@ import okhttp3.Request */ object RelayCommands { private const val USAGE = - "relay …" + "relay …" // ------------------------------------------------------------------ // Flat buckets — a plain list of relay URLs, one Nostr replaceable kind. @@ -216,6 +217,7 @@ object RelayCommands { // `info` is also intercepted in Main before account resolution // (it needs no account); routed here too for when one exists. "info" -> info(rest) + "probe" -> probe(dataDir, rest) "add" -> fanOut(dataDir, Args(rest), add = true) "remove", "rm" -> fanOut(dataDir, Args(rest), add = false) "outbox" -> facetVerb(dataDir, Facet.OUTBOX, rest) @@ -269,6 +271,90 @@ object RelayCommands { } } + // ------------------------------------------------------------------ + // relay probe — the relay census (feeds the NIP-66 reachability cache) + // ------------------------------------------------------------------ + + /** + * `amy relay probe [--timeout SECS] [--concurrency N]` — + * the relay census. Mass-connects the ENTIRE relay universe the local store knows + * (every relay advertised in any stored kind:10002, deduped per host, plus + * everything already in the reachability cache) in parallel waves with a no-op + * REQ, so the "is this relay alive, and how slow?" wait is paid once, up front, + * concurrently — then records per-relay verdicts with real measured `rtt-open` + * into the NIP-66 reachability cache (kind:30166). + * + * Every reachability-aware command reads that cache to skip the dead set without + * dialing it: `graperank crawl` also pre-connects the live set in one storm, + * separating "working but slow" (kept; the crawler's patient park path waits for + * them) from "not working" (skipped entirely). Typical flow the first time: + * `graperank crawl --max-hops 2` (cheap, saves the relay lists) → `relay probe` + * → full `graperank crawl`. (`graperank probe` is kept as an alias.) + */ + suspend fun probe( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.longFlag("timeout", 15L) * 1000 + val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap) + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + val cached = ctx.reachability.snapshot() + val universe = RelayProber.knownRelayUniverse(ctx.store) + cached.live + cached.dead + if (universe.isEmpty()) { + Output.emit( + linkedMapOf( + "probed" to 0, + "note" to "no relays known locally — run `amy graperank crawl` first to gather kind:10002 relay lists", + ), + ) + return 0 + } + + System.err.println( + "[relay-probe] probing ${universe.size} relays in waves of $waveSize " + + "(${timeoutMs / 1000}s per wave; open-files limit ${Context.maxFileDescriptors})", + ) + val result = + RelayProber(ctx.client) { System.err.println(it) } + .probe(universe, timeoutMs, waveSize) + + ctx.reachability.recordProbed(result.reachableRttMs(), result.deadRelays()) + + val rtts = + result.reachable + .map { it.rttOpenMs } + .filter { it >= 0 } + .sorted() + + fun pct(p: Int): Long? = if (rtts.isEmpty()) null else rtts[(rtts.size - 1) * p / 100] + val slowest = + result.reachable + .filter { it.rttOpenMs >= 0 } + .sortedByDescending { it.rttOpenMs } + .take(10) + .map { mapOf("relay" to it.relay.url, "rtt_open_ms" to it.rttOpenMs) } + val authWalled = result.reachable.count { it.error?.startsWith("closed:") == true } + + Output.emit( + linkedMapOf( + "probed" to result.verdicts.size, + "reachable" to result.reachable.size, + "dead" to result.dead.size, + "closed_by_policy" to authWalled, + "elapsed_ms" to result.elapsedMs, + "rtt_open_p50_ms" to pct(50), + "rtt_open_p90_ms" to pct(90), + "rtt_open_p99_ms" to pct(99), + "slowest" to slowest, + ), + ) + } + return 0 + } + // ------------------------------------------------------------------ // Flat-bucket verbs // ------------------------------------------------------------------ From 8b822029e29f42a4881bb8cd7a87d3261f98e80e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 20:52:12 +0000 Subject: [PATCH 177/206] feat(cli): graperank refresh rename, unregister verb, operator keys rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `graperank update` -> `graperank refresh`: the verb says what it does (refresh the WoT record kinds from each author's outbox) and stops colliding with the sync/crawl naming tangle. `update` stays as an alias. - New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`: the missing inverse of `register` — removes matching entries (public + private) from the account's kind:10040 and re-publishes it; without narrowing flags every entry for that provider key is dropped. - `graperank operator providers` -> `operator keys`: it lists the observer -> service-key map, and the old name collided with `graperank providers` (the kind:10040 read). `providers` stays as an alias; the JSON key `providers` becomes `keys` in both the listing and `operator status` (breaking, matches the rename). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 8 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 13 +- .../amethyst/cli/commands/GrapeRankCommand.kt | 120 ++++++++++++++++-- 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/cli/README.md b/cli/README.md index 91446e2e74..4a6cf5a7a7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -392,8 +392,10 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `graperank score` is the local-only variant (same as `--offline`). | | `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | | `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | -| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | +| `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) | +| `amy graperank operator [status \| relay … \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | +| `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | #### GrapeRank scores are persisted locally, then published (NIP-85) @@ -432,7 +434,7 @@ negentropy-reconcile gets the full set published event-by-event instead. When the observer is your own account (we hold the key), `publish` also writes their kind:10040 pointing `30382:rank → serviceKey @ operator relay` to their outbox, so clients can find the cards. For a third-party observer, `graperank operator -providers` prints the `observer → service-pubkey` mapping to wire their +keys` prints the `observer → service-pubkey` mapping to wire their kind:10040 out-of-band. ### Direct messages (NIP-17) @@ -553,7 +555,7 @@ the last facet removes R entirely. | `amy relay add URL` / `remove URL` | Fan-out to the transport lists (nip65 `both` + `dm` + `key-package`). | | `amy relay list` | Print every configured relay bucket. | | `amy relay publish-lists` | Broadcast every configured relay list to the union of your relays. | -| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`update`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | +| `amy relay probe [--timeout SECS] [--concurrency N]` | The relay census: mass-connect every relay the local store knows (all stored kind:10002 relays + the reachability cache) in parallel waves and record live/dead + measured `rtt-open` into the NIP-66 reachability cache (kind:30166). Reachability-aware commands (`graperank crawl`/`refresh`) read it to skip dead relays and pre-connect live ones. (`amy graperank probe` remains as an alias.) | ### Local store maintenance diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 6144320f7b..217e53814d 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -61,7 +61,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. | | NIP-65 outbox model queries | 🆕 | | -| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); every score run persists kind:30382 `ContactCardEvent` cards to the local store (diffed against prior ranks, kind:5 retractions), `publish` mirrors that set to the operator relays via NIP-77 up-sync, `rank` reads cards back, plus `register` / `unregister` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | 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 f4b2d7a5ef..2c76144d85 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -646,7 +646,7 @@ private fun printUsage() { | storm (seeded from the reachability cache). | graperank probe alias for `relay probe` (the census moved there — | it feeds the shared NIP-66 reachability cache). - | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds + | graperank refresh [--down] [--up] refresh every locally-known author's WoT record kinds | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write | [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per @@ -654,14 +654,19 @@ private fun printUsage() { | the deletion settle downloads the relay's kind:5 when | an uploaded record was rejected (author retracted it). | Falls back to a full paged download when a relay - | can't reconcile via negentropy. + | can't reconcile via negentropy. (`update` is the + | pre-rename alias.) | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, - | |providers] independent of accounts): relay sets where cards + + | |keys] independent of accounts): relay sets where cards + | retractions publish; status shows master + relays; - | providers lists observer -> service-pubkey. + | keys lists observer -> service-pubkey. | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the | [--private] 30382:rank provider at your first outbox relay). + | graperank unregister PROVIDER remove matching provider entries (public + private) + | [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/ + | --relay narrow the match, else every entry for + | that provider key is dropped. | graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers | [--timeout SECS] (default: active account). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index ac836681fb..324945b1da 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -102,7 +102,8 @@ import kotlin.math.roundToInt * store first, `--refresh` to drain providers' relays): the consumer side. * - `amy graperank register` — advertise a `30382:rank` provider in the * account's kind:10040 [TrustProviderListEvent] (defaults to self, so a - * provider publishing ranks announces where to find them). + * provider publishing ranks announces where to find them); + * `amy graperank unregister PROVIDER` removes entries again. * - `amy graperank providers [USER]` — list a user's trusted providers. */ object GrapeRankCommand { @@ -196,16 +197,19 @@ object GrapeRankCommand { // NIP-05, or nothing) is the OBSERVER positional for a score computation. when (tail.firstOrNull()) { "register" -> register(dataDir, tail.drop(1).toTypedArray()) + "unregister" -> unregister(dataDir, tail.drop(1).toTypedArray()) "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is - // canonical (disambiguates from negentropy `amy sync` / `graperank update`). + // canonical (disambiguates from negentropy `amy sync` / `graperank refresh`). "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) // The relay census outgrew graperank (it feeds the shared NIP-66 // reachability cache every command reads) and moved to `amy relay // probe`; this alias keeps the old spelling working. "probe" -> RelayCommands.probe(dataDir, tail.drop(1).toTypedArray()) - "update" -> update(dataDir, tail.drop(1).toTypedArray()) + // `refresh` is canonical (it refreshes the WoT record kinds from each + // author's outbox); `update` is the pre-rename back-compat alias. + "refresh", "update" -> refresh(dataDir, tail.drop(1).toTypedArray()) "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) "publish" -> publish(dataDir, tail.drop(1).toTypedArray()) "rank" -> rank(dataDir, tail.drop(1).toTypedArray()) @@ -544,7 +548,7 @@ object GrapeRankCommand { } /** - * `amy graperank update [flags]` — refresh every locally-known author's WoT + * `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the * next `graperank score` runs on current data without a full follow-graph crawl. * @@ -563,7 +567,7 @@ object GrapeRankCommand { * `--report-limit N` (per-relay rows in the JSON, default 50), * `--down` / `--up` / `--no-sync-deletions`. */ - private suspend fun update( + private suspend fun refresh( dataDir: DataDir, rest: Array, ): Int { @@ -854,13 +858,15 @@ object GrapeRankCommand { } /** - * `amy graperank operator [status | relay … | providers]` + * `amy graperank operator [status | relay … | keys]` * * Manage the machine's operator keys used to sign trusted-assertion cards. - * - `status` (default): master pubkey, configured relay(s), provider count. + * - `status` (default): master pubkey, configured relay(s), service-key count. * - `relay …`: set the operator relay(s) the cards + retractions publish * to; creates the operator master on first use. - * - `providers`: the observer -> provider-pubkey mapping learned so far. + * - `keys` (alias: the pre-rename `providers`, which collided with + * `graperank providers`): the observer -> service-key mapping derived so + * far — what a third-party observer wires into their kind:10040. */ private fun operator( dataDir: DataDir, @@ -877,11 +883,11 @@ object GrapeRankCommand { 0 } - "providers" -> { + "keys", "providers" -> { Output.emit( mapOf( "master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null, - "providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, + "keys" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, ), ) 0 @@ -896,14 +902,14 @@ object GrapeRankCommand { "initialized" to true, "master_pubkey" to opKeys.masterPubKey(), "relays" to opKeys.operatorRelays().map { it.url }, - "providers" to opKeys.providers().size, + "keys" to opKeys.providers().size, ), ) } 0 } - else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)") + else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | keys)") } } @@ -990,6 +996,96 @@ object GrapeRankCommand { } } + /** + * `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL] [--timeout SECS]` + * + * The inverse of [register]: drop matching provider entries — public AND + * private — from the account's kind:10040 [TrustProviderListEvent] and + * re-publish it. PROVIDER is required; `--service` / `--relay` narrow the + * match when the same key is listed for several services or relays — without + * them, every entry for that provider key is removed. Fetches the freshest + * list first so the removal applies to the current provider set. + */ + private suspend fun unregister( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val providerArg = + args.positionalOrNull(0) + ?: args.flag("provider") + ?: return Output.error("bad_args", "usage: amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]") + val serviceArg = args.flag("service") + val relayArg = args.flag("relay") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val service = + serviceArg?.let { + ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + } + val relay = + relayArg?.let { + RelayUrlNormalizer.normalizeOrNull(it) ?: return Output.error("bad_args", "--relay is not a valid relay URL") + } + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val provider = ctx.requireUserHex(providerArg) + val outbox = ctx.outboxRelays() + + val latest = + fetchLatestProviderList(ctx, ctx.identity.pubKeyHex, outbox, timeoutMs) + ?: return Output.error("not_found", "no kind:10040 provider list found for this account") + + fun matches(tag: ServiceProviderTag) = + tag.pubkey == provider && + (service == null || tag.service == service) && + (relay == null || tag.relayUrl == relay) + + val publicMatches = latest.serviceProviders().filter(::matches) + val privateMatches = + latest + .privateTags(ctx.signer) + ?.serviceProviders() + .orEmpty() + .filter(::matches) + val toRemove = (publicMatches + privateMatches).distinct() + + if (toRemove.isEmpty()) { + Output.emit( + mapOf( + "provider" to provider, + "changed" to false, + "removed" to emptyList(), + "based_on" to latest.id, + ), + ) + return 0 + } + + // remove() strips the tag from both the public and the private set, + // re-signing each round; only the final version is published. + var event = latest + for (tag in toRemove) { + event = TrustProviderListEvent.remove(event, tag, ctx.signer) + } + + val ack = ctx.publish(event, outbox) + Output.emit( + mapOf( + "provider" to provider, + "changed" to true, + "removed" to toRemove.map { mapOf("service" to it.service.toValue(), "relay" to it.relayUrl.url) }, + "event_id" to event.id, + "based_on" to latest.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } + /** * `amy graperank providers [USER] [--refresh] [--timeout SECS]` * From 8060dc0bb1c884cb6e60f439e714b77f651a080f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 16:15:14 -0400 Subject: [PATCH 178/206] 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 179/206] 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 180/206] 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 181/206] =?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 883365d6a5c446a3f700a061507af5b164f8d194 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 21:18:36 +0000 Subject: [PATCH 182/206] feat(cli): graperank status verb, flag consistency, deprecate the sync alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `graperank status`: read-only local inventory with no network, no signing, and no side effects — WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + newest-record age, operator/service-key state, and persisted card + retraction counts per observer. Reads the reachability cache through a throwaway signer so a fresh machine's status never lazily creates the operator master. - Flag consistency: --relay-concurrency and --concurrency are now accepted interchangeably on `graperank refresh`, `graperank publish`, and `relay probe` (each keeps its documented spelling as canonical), and the help text states what --timeout means per verb (per-REQ drain 10s for crawl/score, idle watchdog 30s for refresh/publish, per wave 15s for probe, drain 8s for rank/register/providers). - `graperank sync` now prints a deprecation warning before running crawl — the name points at the wrong concept since the negentropy record refresh became `graperank refresh` — and is removed from the docs; removal comes in a later release. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 1 + .../com/vitorpamplona/amethyst/cli/Main.kt | 17 ++- .../amethyst/cli/commands/GrapeRankCommand.kt | 118 ++++++++++++++++-- .../amethyst/cli/commands/RelayCommands.kt | 6 +- 4 files changed, 126 insertions(+), 16 deletions(-) diff --git a/cli/README.md b/cli/README.md index 4a6cf5a7a7..1350cd478b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -393,6 +393,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | | `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | | `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) | +| `amy graperank status` | Read-only local inventory, no network, no signing: WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + age, operator/service-key state, and the persisted card + retraction counts per observer. | | `amy graperank operator [status \| relay … \| keys]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where `publish` sends cards + retractions; `status` shows the master pubkey and relays; `keys` lists the observer → service-pubkey map (`providers` is the pre-rename alias). | | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | | `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. | 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 2c76144d85..cf5f6d2e4e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -488,7 +488,8 @@ private fun printUsage() { | [--concurrency N] knows and record live/dead + measured rtt-open | into the reachability cache (NIP-66 kind:30166), | so reachability-aware commands (graperank crawl/ - | update) skip dead relays and wait once + | refresh) skip dead relays and wait once + | (--timeout: per wave, default 15s) | outbox USER [--refresh] show USER's NIP-65 read/write relays (outbox model) | [--timeout SECS] (USER: npub|nprofile|hex|name@domain) | @@ -616,7 +617,8 @@ private fun printUsage() { | [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox | [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every | [--offline] [--timeout SECS] discovered user has been checked (no user cap; - | [--diagnose] [--min-rank N] --max-hops bounds follow distance, e.g. 8; + | [--diagnose] [--min-rank N] --timeout: per-REQ drain, default 10s; + | --max-hops bounds follow distance, e.g. 8; | --diagnose dumps per-relay telemetry: outcome | mix, yield, latency, and a LIVE/DEAD + limits | classification table of every relay contacted). @@ -637,9 +639,15 @@ private fun printUsage() { | re-signed; full-set publish fallback when a relay | can't reconcile). Also refreshes the observer's | kind:10040 pointer when we hold their key. + | --timeout: idle watchdog per relay, default 30s. | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank | [--refresh] [--timeout SECS] per provider, local store first, --refresh drains - | the operator/declared/bootstrap relays. + | the operator/declared/bootstrap relays + | (--timeout: drain, default 8s). + | graperank status read-only local inventory, no network: WoT record + | counts (do I need to crawl again?), reachability + | cache size + age, operator state, and the + | persisted card set per observer. | graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/ | [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring. | [--no-preconnect] Pre-connects every known-live relay in one parallel @@ -655,7 +663,8 @@ private fun printUsage() { | an uploaded record was rejected (author retracted it). | Falls back to a full paged download when a relay | can't reconcile via negentropy. (`update` is the - | pre-rename alias.) + | pre-rename alias; --timeout: idle watchdog per + | relay, default 30s.) | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, | |keys] independent of accounts): relay sets where cards + | retractions publish; status shows master + relays; diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 324945b1da..121ee79953 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder 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.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -44,12 +46,16 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.withContext @@ -77,9 +83,12 @@ import kotlin.math.roundToInt * fetches; the score is a pure function over the store; every score run persists * its result as locally-signed NIP-85 kind:30382 cards): * - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's - * kind 3/10000/1984/10002 into the local store (aliased as the former `sync`). - * Idempotent and cumulative, so run it a few times to make sure everything is - * loaded. Scores nothing. + * kind 3/10000/1984/10002 into the local store. Idempotent and cumulative, so + * run it a few times to make sure everything is loaded. Scores nothing. + * (`sync` is a deprecated alias — it warns and will be removed.) + * - `amy graperank status` — read-only inventory of all of the above: WoT record + * counts, reachability-cache freshness, operator state, persisted card sets. + * Answers "do I need to crawl again?" with no network and no signing. * - `amy graperank score [OBSERVER]` — local only: build the graph from the store, * score (same as bare `--offline`), and ALWAYS reconcile the result into the * store as kind:30382 [ContactCardEvent] cards signed by the observer's @@ -200,9 +209,19 @@ object GrapeRankCommand { "unregister" -> unregister(dataDir, tail.drop(1).toTypedArray()) "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) - // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is - // canonical (disambiguates from negentropy `amy sync` / `graperank refresh`). - "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) + "crawl" -> crawl(dataDir, tail.drop(1).toTypedArray()) + // Deprecated pre-rename alias for `crawl`. Actively misleading now that + // the negentropy record refresh is `graperank refresh` (and `amy sync` + // is the generic negentropy verb), so it warns before it runs — next + // step is removal. + "sync" -> { + System.err.println( + "[graperank] `graperank sync` is deprecated and will be removed — use `graperank crawl` " + + "(or `graperank refresh` to re-sync known authors' records).", + ) + crawl(dataDir, tail.drop(1).toTypedArray()) + } + "status" -> status(dataDir) // The relay census outgrew graperank (it feeds the shared NIP-66 // reachability cache every command reads) and moved to `amy relay // probe`; this alias keeps the old spelling working. @@ -505,9 +524,9 @@ object GrapeRankCommand { } /** - * `amy graperank crawl [OBSERVER]` — network-only WoT data crawl (aliased as the - * former `sync`). Crawls the reachable follow/mute/report graph into the local - * store (kind 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring. + * `amy graperank crawl [OBSERVER]` — network-only WoT data crawl. Crawls the + * reachable follow/mute/report graph into the local store (kind + * 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring. * Idempotent + cumulative: run it a few times to make sure everything is loaded, * then `graperank score`. */ @@ -547,6 +566,80 @@ object GrapeRankCommand { return 0 } + /** + * `amy graperank status` — read-only inventory of everything a GrapeRank run + * depends on, straight from the local store: WoT record counts (the "do I + * need to crawl again?" answer), reachability-cache size + freshness, + * operator/service-key state, and the persisted card set per observer. + * No network, no signing, no side effects. + */ + private suspend fun status(dataDir: DataDir): Int { + Context.openOrAnonymous(dataDir).use { ctx -> + // Deliberately no ctx.prepare(): status must stay offline (nothing here + // needs a relay connection or marmot state). + suspend fun countKind(kind: Int) = ctx.store.count(Filter(kinds = listOf(kind))) + + // The store keeps only the newest replaceable event per author, so the + // kind:3 count is "users whose follow list we hold" — the graph size a + // `score` would see. + val contactLists = countKind(ContactListEvent.KIND) + + // Read-only reachability view: ctx.reachability would lazily derive the + // monitor key and thereby CREATE the operator master on a fresh machine; + // a throwaway signer reads the same kind:30166 records without that + // side effect (the signer is only used for writes). + val reach = RelayReachabilityStore(store = ctx.store, signer = NostrSignerInternal(KeyPair())).snapshot() + val newestReachRecord = + ctx.store + .query(Filter(kinds = listOf(RelayDiscoveryEvent.KIND), limit = 1)) + .firstOrNull() + ?.createdAt + + val opKeys = ctx.dataDir.operatorKeys() + val cards = + opKeys.providers().map { (observer, rec) -> + linkedMapOf( + "observer" to observer, + "provider_pubkey" to rec.providerPubKey, + "cards" to ctx.store.count(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(rec.providerPubKey))), + "retractions" to ctx.store.count(Filter(kinds = listOf(DeletionEvent.KIND), authors = listOf(rec.providerPubKey))), + ) + } + + Output.emit( + linkedMapOf( + "store" to + linkedMapOf( + "profiles" to countKind(MetadataEvent.KIND), + "contact_lists" to contactLists, + "mute_lists" to countKind(MuteListEvent.KIND), + "reports" to countKind(ReportEvent.KIND), + "relay_lists" to countKind(AdvertisedRelayListEvent.KIND), + ), + "reachability" to + linkedMapOf( + "live" to reach.live.size, + "dead" to reach.dead.size, + "newest_record_age_s" to newestReachRecord?.let { (TimeUtils.now() - it).coerceAtLeast(0) }, + ), + "operator" to + if (opKeys.exists()) { + linkedMapOf( + "initialized" to true, + "master_pubkey" to opKeys.masterPubKey(), + "relays" to opKeys.operatorRelays().map { it.url }, + ) + } else { + linkedMapOf("initialized" to false) + }, + "cards" to cards, + "note" to if (contactLists == 0) "no contact lists in the local store — run `amy graperank crawl` first" else null, + ), + ) + } + return 0 + } + /** * `amy graperank refresh [flags]` (alias: `update`) — refresh every locally-known author's WoT * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the @@ -596,7 +689,7 @@ object GrapeRankCommand { down = downFlag || !upFlag, up = upFlag || !downFlag, syncDeletions = !args.bool("no-sync-deletions"), - relayConcurrency = args.intFlag("relay-concurrency", 4), + relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4)), authorChunk = args.intFlag("author-chunk", 500), minAuthors = args.intFlag("min-authors", 1), idleTimeoutMs = args.longFlag("timeout", 30L) * 1000, @@ -679,7 +772,10 @@ object GrapeRankCommand { val args = Args(rest) val observerArg = args.positionalOrNull(0) val relayArg = args.flag("relay") - val relayConcurrency = args.intFlag("relay-concurrency", 4) + // --relay-concurrency is canonical for "relays worked at once" across the + // graperank verbs; --concurrency is accepted everywhere as its alias. + val relayConcurrency = args.intFlag("relay-concurrency", args.intFlag("concurrency", 4)) + // Idle watchdog per relay reconcile (not a total budget), like `refresh`. val idleTimeoutMs = args.longFlag("timeout", 30L) * 1000 Context.open(dataDir).use { ctx -> diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 33207c6790..255808199e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -296,8 +296,12 @@ object RelayCommands { rest: Array, ): Int { val args = Args(rest) + // Per probe WAVE, not per relay or total — a wave's stragglers are cut off + // together when it elapses. val timeoutMs = args.longFlag("timeout", 15L) * 1000 - val waveSize = args.intFlag("concurrency", Context.defaultPreconnectCap) + // Relays dialed at once; --relay-concurrency accepted as the alias the + // graperank verbs spell it with. + val waveSize = args.intFlag("concurrency", args.intFlag("relay-concurrency", Context.defaultPreconnectCap)) Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() From 65eac0f359992ae1e8a1f51605240d8e2d82ab5c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 21:33:49 +0000 Subject: [PATCH 183/206] refactor(cli): remove the graperank `sync` alias; reject non-64-hex user ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `graperank sync` is gone — `crawl` is the only spelling. Because the dispatch treats any non-verb first token as an OBSERVER, this exposed a latent leniency in the shared resolver: decodePublicKeyAsHexOrNull's fallback runs Hex.decode without a length check, so a short bech32/hex-ish word like `sync` decodes to a bogus few-byte "pubkey" instead of failing — turning `graperank sync` into a silent network crawl over garbage. Context.requireUserHex now requires the resolved value to be exactly 64 hex chars (a pubkey is always 32 bytes), so a mistyped OBSERVER/USER on any amy command errors cleanly (exit 2) instead of silently scoring/fetching against a garbage key. `graperank sync` now returns `bad_args` rather than crawling. Also drops the never-built `graperank compare` idea from the roadmap. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- .../com/vitorpamplona/amethyst/cli/Context.kt | 19 +++++++++++++++---- .../amethyst/cli/commands/GrapeRankCommand.kt | 12 ------------ 2 files changed, 15 insertions(+), 16 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..90ca163159 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -470,11 +470,22 @@ class Context( * accept the exact same identifier formats. Throws on unrecognised input — * command handlers catch [IllegalArgumentException] at the top level and * translate to `{"error": "bad_args"}`. + * + * A pubkey is always 32 bytes, so we require exactly 64 hex chars: the shared + * resolver's fallback runs a lenient `Hex.decode` that turns a short + * bech32/hex-ish word (e.g. a mistyped verb like `sync`) into a bogus few-byte + * "pubkey" instead of failing — this rejects that so a bad OBSERVER/USER errors + * cleanly rather than silently scoring/fetching garbage. */ - suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey = - com.vitorpamplona.quartz.nip05DnsIdentifiers - .resolveUserHexOrNull(input, nip05Client) - ?: throw IllegalArgumentException("Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)") + suspend fun requireUserHex(input: String): com.vitorpamplona.quartz.nip01Core.core.HexKey { + val notResolved = "Could not resolve user: '$input' (accepts npub, nprofile, 64-hex, or name@domain.tld)" + val hex = + com.vitorpamplona.quartz.nip05DnsIdentifiers + .resolveUserHexOrNull(input, nip05Client) + ?: throw IllegalArgumentException(notResolved) + require(hex.length == 64) { notResolved } + return hex + } /** * Outbox / NIP-65 write relays for this account. Read from the diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt index 121ee79953..302fc6d02e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -85,7 +85,6 @@ import kotlin.math.roundToInt * - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's * kind 3/10000/1984/10002 into the local store. Idempotent and cumulative, so * run it a few times to make sure everything is loaded. Scores nothing. - * (`sync` is a deprecated alias — it warns and will be removed.) * - `amy graperank status` — read-only inventory of all of the above: WoT record * counts, reachability-cache freshness, operator state, persisted card sets. * Answers "do I need to crawl again?" with no network and no signing. @@ -210,17 +209,6 @@ object GrapeRankCommand { "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) "crawl" -> crawl(dataDir, tail.drop(1).toTypedArray()) - // Deprecated pre-rename alias for `crawl`. Actively misleading now that - // the negentropy record refresh is `graperank refresh` (and `amy sync` - // is the generic negentropy verb), so it warns before it runs — next - // step is removal. - "sync" -> { - System.err.println( - "[graperank] `graperank sync` is deprecated and will be removed — use `graperank crawl` " + - "(or `graperank refresh` to re-sync known authors' records).", - ) - crawl(dataDir, tail.drop(1).toTypedArray()) - } "status" -> status(dataDir) // The relay census outgrew graperank (it feeds the shared NIP-66 // reachability cache every command reads) and moved to `amy relay From 1a8a4012a6297ec3ab8000699751c434486d6ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 21:44:26 +0000 Subject: [PATCH 184/206] docs(cli): audit graperank docs against code; simplify the --help block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audited every graperank verb/flag in the code against the three doc surfaces and fixed the drift: - Main.kt `--help`: rewrote the GrapeRank block to match the terse house style (one line per command, key flags on continuation lines) — it had grown to ~68 lines of prose. Now ~33 lines, reordered into pipeline order (crawl -> score -> publish, then rank/status/refresh, then the provider/operator discovery group), with the per-verb timeout-semantics prose dropped (it lives in the KDoc). Verbs and primary flags verified against the dispatch and each function's arg reads. - README: added the missing `graperank crawl` and `graperank score` rows (stage 1 and 2 of the pipeline — crawl had no table row at all, score was only mentioned inline) and labelled the three pipeline stages. No stale references remain (no `graperank sync`, no removed publish/ bench flags, `operator providers`/`update` only as documented aliases). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 6 +- .../com/vitorpamplona/amethyst/cli/Main.kt | 98 ++++++------------- 2 files changed, 36 insertions(+), 68 deletions(-) diff --git a/cli/README.md b/cli/README.md index 1350cd478b..925c456afc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -389,8 +389,10 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | | `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | -| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `graperank score` is the local-only variant (same as `--offline`). | -| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | +| `amy graperank [OBSERVER] [--offline] [--min-rank N]` | Crawl + score: compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph, then persist the result. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. **Every score run persists its result locally**: the ranks (cutoff `--min-rank`, default 2) are reconciled into the shared store as NIP-85 kind:30382 cards signed by a per-observer **service key** — changed ranks re-signed, unchanged skipped (no event-id churn), dropped targets retracted (kind:5). `--offline` skips the crawl. | +| `amy graperank crawl [OBSERVER] [--max-hops N] [--no-preconnect]` | Pipeline stage 1 — network only: crawl the follow/mute/report graph (kind 3/10000/1984/10002) into the local store, no scoring. Idempotent and cumulative: run it a few times to load everything, then `score`. | +| `amy graperank score [OBSERVER]` | Pipeline stage 2 — local only: score from the store and persist the cards (identical to bare `--offline`; same scoring flags). No network, so re-run with different `--rigor`/`--attenuation`/`--min-rank` without re-crawling. | +| `amy graperank publish [OBSERVER] [--relay URL[,URL…]]` | Pipeline stage 3 — transport only: make the operator relay(s) converge to the locally persisted card set — one NIP-77 up-only reconcile per relay over the service key's kind:30382 + kind:5 (nothing is re-scored or re-signed; a relay that can't reconcile gets the full set published instead). Also refreshes the observer's kind:10040 pointer when we hold their key. | | `amy graperank rank USER [--provider PUBKEY] [--refresh]` | The consumer side: read the kind:30382 cards about USER — one rank per provider, newest card each. Local store first; `--refresh` (or a miss) drains the operator relays, the relays your kind:10040 declares, and the bootstrap set. | | `amy graperank refresh [--down] [--up]` | Refresh every locally-known author's WoT record kinds (0/3/10002/1984) from their own outbox: one NIP-77 negentropy reconcile per write relay scoped to its authors, so the next `score` runs on current data without a full re-crawl. (`update` is the pre-rename alias.) | | `amy graperank status` | Read-only local inventory, no network, no signing: WoT record counts in the store (the "do I need to crawl again?" answer), reachability-cache size + age, operator/service-key state, and the persisted card + retraction counts per observer. | 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 cf5f6d2e4e..a3bfeb8f28 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -612,72 +612,38 @@ private fun printUsage() { | (USER: npub|nprofile|hex|name@domain) | |Web of Trust (GrapeRank): - | graperank [OBSERVER] compute subjective trust scores (0..1) for every - | [--limit N] [--min-score X] user reachable in the follow/mute/report graph. - | [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox - | [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every - | [--offline] [--timeout SECS] discovered user has been checked (no user cap; - | [--diagnose] [--min-rank N] --timeout: per-REQ drain, default 10s; - | --max-hops bounds follow distance, e.g. 8; - | --diagnose dumps per-relay telemetry: outcome - | mix, yield, latency, and a LIVE/DEAD + limits - | classification table of every relay contacted). - | OBSERVER: npub|nprofile|hex|name@domain (default: - | active account). --offline scores from the local - | store only. EVERY score run persists its result - | locally as NIP-85 kind:30382 cards signed by a - | per-observer service key (ranks >= --min-rank, - | default 2): changed ranks re-signed, unchanged - | skipped, dropped targets retracted (kind:5). - | `graperank publish` pushes that set to relays. - | graperank score [OBSERVER] local only: build the graph from the store and - | score (same as bare --offline; same flags). Fast - | and param-tunable without re-crawling. - | graperank publish [OBSERVER] transport only: make the operator relay(s) match - | [--relay URL[,URL…]] [--timeout SECS] the local card set via one NIP-77 up-only - | [--relay-concurrency N] reconcile per relay (nothing re-scored or - | re-signed; full-set publish fallback when a relay - | can't reconcile). Also refreshes the observer's - | kind:10040 pointer when we hold their key. - | --timeout: idle watchdog per relay, default 30s. - | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER — one rank - | [--refresh] [--timeout SECS] per provider, local store first, --refresh drains - | the operator/declared/bootstrap relays - | (--timeout: drain, default 8s). - | graperank status read-only local inventory, no network: WoT record - | counts (do I need to crawl again?), reachability - | cache size + age, operator state, and the - | persisted card set per observer. - | graperank crawl [OBSERVER] network only: crawl the WoT graph (kind 3/10000/ - | [--max-hops N] [--preconnect-cap N] 1984/10002) into the local store without scoring. - | [--no-preconnect] Pre-connects every known-live relay in one parallel - | storm (seeded from the reachability cache). - | graperank probe alias for `relay probe` (the census moved there — - | it feeds the shared NIP-66 reachability cache). - | graperank refresh [--down] [--up] refresh every locally-known author's WoT record kinds - | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all - | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write - | [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per - | relay scoped to its authors. Bidirectional by default; - | the deletion settle downloads the relay's kind:5 when - | an uploaded record was rejected (author retracted it). - | Falls back to a full paged download when a relay - | can't reconcile via negentropy. (`update` is the - | pre-rename alias; --timeout: idle watchdog per - | relay, default 30s.) - | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, - | |keys] independent of accounts): relay sets where cards + - | retractions publish; status shows master + relays; - | keys lists observer -> service-pubkey. - | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so - | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the - | [--private] 30382:rank provider at your first outbox relay). - | graperank unregister PROVIDER remove matching provider entries (public + private) - | [--service KIND:TAG] [--relay URL] from your kind:10040 and re-publish it; --service/ - | --relay narrow the match, else every entry for - | that provider key is dropped. - | graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers - | [--timeout SECS] (default: active account). + | graperank [OBSERVER] crawl + score: subjective trust (0..1) over the + | [--min-rank N] [--offline] follow/mute/report graph, then persist the result + | [--limit N] [--min-score X] as local NIP-85 kind:30382 cards (ranks >= + | [--rigor X] [--attenuation X] --min-rank, default 2). --offline skips the crawl. + | [--max-hops N] [--diagnose] OBSERVER: npub|nprofile|hex|name@domain (self). + | graperank crawl [OBSERVER] network only: crawl the graph (kind 3/10000/1984/ + | [--max-hops N] [--max-rounds N] 10002) into the local store, no scoring. + | [--no-preconnect] [--preconnect-cap N] Idempotent — run a few times to load everything. + | graperank score [OBSERVER] local only: score from the store + persist cards + | (= bare --offline; same flags). No network. + | graperank publish [OBSERVER] push local cards to the operator relay(s) via a + | [--relay URL[,URL…]] [--timeout SECS] NIP-77 up-sync (nothing re-scored), and refresh + | [--relay-concurrency N] the observer's kind:10040 when we hold their key. + | graperank rank USER [--provider PUBKEY] read the kind:30382 cards about USER, one rank per + | [--refresh] [--timeout SECS] provider; --refresh drains relays on a miss. + | graperank status read-only local inventory: record counts, cache + | freshness, operator state, cards per observer. + | graperank refresh [--down] [--up] re-sync known authors' records (kind 0/3/10002/ + | [--relay-concurrency N] [--author-chunk N] 1984) from their outboxes via NIP-77, so score + | [--min-authors N] [--report-limit N] runs on current data. (`update` is the alias.) + | [--no-sync-deletions] [--timeout SECS] + | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 + | [--service KIND:TAG] [--relay URL] (default: self as 30382:rank at your 1st outbox). + | [--private] + | graperank unregister PROVIDER remove matching entries from your kind:10040; + | [--service KIND:TAG] [--relay URL] --service/--relay narrow, else all for that key. + | graperank providers [USER] [--refresh] list a user's declared NIP-85 providers. + | [--timeout SECS] + | graperank operator operator keys (~/.amy/operator/): `relay URL…` + | [status | relay URL… | keys] sets the publish target; `keys` maps observer + | -> service-key. (default: status) + | graperank probe alias for `relay probe` (the relay census). | |Zaps (NIP-57): | zap user USER SATS build a profile zap-request, fetch a BOLT11 From 83c985819f272fa638dfcc0e337bb2cb37c9ac3a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 14 Jul 2026 18:10:21 -0400 Subject: [PATCH 185/206] 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 f628783186c0892b7648188f7f74ccee874a7060 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:43:44 +0000 Subject: [PATCH 186/206] refactor(cli): rename `amy wot` -> `amy fof` (follows-of-follows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wot` claimed the whole web-of-trust concept for what is actually a cheap single-hop metric — the count of your follows who also follow X. The real computed web of trust is `graperank`. Renamed the command (and WotCommand -> FofCommand) to `fof`, reframing its KDoc/usage away from "trust," and kept `wot` as a deprecation alias that warns and points at both `fof` and `graperank`. Also documents the command for the first time: `amy wot` had no --help block and no README row. Added both (help block + three README rows for get/list/sync), so the follows-of-follows score and its relationship to graperank are now discoverable. JSON output shape is unchanged. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56 --- cli/README.md | 3 +++ .../com/vitorpamplona/amethyst/cli/Main.kt | 19 +++++++++++++-- .../commands/{WotCommand.kt => FofCommand.kt} | 24 ++++++++++++------- 3 files changed, 35 insertions(+), 11 deletions(-) rename cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/{WotCommand.kt => FofCommand.kt} (90%) diff --git a/cli/README.md b/cli/README.md index 925c456afc..468211a712 100644 --- a/cli/README.md +++ b/cli/README.md @@ -400,6 +400,9 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | | `amy graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]` | The inverse of `register`: remove matching entries (public + private) from your kind:10040 and re-publish it. `--service`/`--relay` narrow the match; without them every entry for that provider key is dropped. | | `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | +| `amy fof get USER` | Follows-of-follows social proof: how many accounts you follow also follow USER. Single-hop, cheap — **not** the computed web of trust (that's `graperank`). Read from the local store; run `fof sync` first to freshen it. | +| `amy fof list [--threshold N] [--limit N]` | Rank accounts by that social-proof score — who's most-followed inside your network (discovery). Defaults: `--threshold 1`, `--limit 50`. | +| `amy fof sync [--timeout SECS]` | Pull your follows' latest kind:3 from the index relays so the next `get`/`list` is current. (`amy wot …` remains as a deprecation alias for all three.) | #### GrapeRank scores are persisted locally, then published (NIP-85) 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 a3bfeb8f28..e5f29ecbd0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.cli.commands.EncryptCommand import com.vitorpamplona.amethyst.cli.commands.EventCommand import com.vitorpamplona.amethyst.cli.commands.FetchCommand import com.vitorpamplona.amethyst.cli.commands.FilterCommand +import com.vitorpamplona.amethyst.cli.commands.FofCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand import com.vitorpamplona.amethyst.cli.commands.GiftCommands import com.vitorpamplona.amethyst.cli.commands.GitCommands @@ -70,7 +71,6 @@ import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand -import com.vitorpamplona.amethyst.cli.commands.WotCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands @@ -292,7 +292,14 @@ private suspend fun dispatch(argv: Array): Int { "podcast" -> PodcastCommands.dispatch(dataDir, tail) "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) - "wot" -> WotCommand.dispatch(dataDir, tail) + "fof" -> FofCommand.dispatch(dataDir, tail) + // `wot` overclaimed the whole web-of-trust concept for a cheap + // single-hop follower count; renamed to `fof` (follows-of-follows). + // Kept as a warning alias — the real WoT engine is `graperank`. + "wot" -> { + System.err.println("[amy] `wot` is deprecated — use `fof` (follows-of-follows). The computed web of trust is `graperank`.") + FofCommand.dispatch(dataDir, tail) + } else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -645,6 +652,14 @@ private fun printUsage() { | -> service-key. (default: status) | graperank probe alias for `relay probe` (the relay census). | + |Follows-of-follows (social proof — the cheap counterpart to graperank): + | fof get USER USER's score: how many accounts you follow also + | follow them (single-hop social proof, not trust). + | fof list [--threshold N] [--limit N] accounts ranked by that score — who's most + | followed inside your network (default N: 1 / 50). + | fof sync [--timeout SECS] refresh your follows' kind:3 from the index relays + | so the next get/list is current (`wot` = alias). + | |Zaps (NIP-57): | zap user USER SATS build a profile zap-request, fetch a BOLT11 | [--comment X] [--anon|--private] invoice from the recipient's LN service diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt similarity index 90% rename from cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt rename to cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt index c81ce32c4b..a10d700fbc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FofCommand.kt @@ -38,15 +38,21 @@ import kotlinx.coroutines.cancel import java.util.Collections /** - * `amy wot ` — Web-of-Trust score queries. + * `amy fof ` — follows-of-follows social-proof scores. * * The score for a pubkey X is the count of accounts in the active user's - * kind-3 follow set who also follow X. `get` and `list` are read-only — - * they hydrate the score map from whatever kind-3 events already live in - * the local event store. `sync` pulls fresh kind-3 events from the + * kind:3 follow set who also follow X — cheap single-hop social proof, NOT + * the computed web of trust (that's `amy graperank`). `get` and `list` are + * read-only — they hydrate the score map from whatever kind:3 events already + * live in the local event store. `sync` pulls fresh kind:3 events from the * configured relay pool so the next `get` / `list` is up to date. + * + * - `fof get USER` — X's score (how many of your follows follow X). + * - `fof list` — accounts ranked by score (discovery: who's most + * followed inside your network). + * - `fof sync` — refresh your follows' kind:3 from relays. */ -object WotCommand { +object FofCommand { suspend fun dispatch( dataDir: DataDir, rest: Array, @@ -61,13 +67,13 @@ object WotCommand { } } - private fun usage(): Int = Output.error("bad_args", "wot ") + private fun usage(): Int = Output.error("bad_args", "fof ") private suspend fun get( dataDir: DataDir, rest: Array, ): Int { - if (rest.isEmpty()) return Output.error("bad_args", "wot get ") + if (rest.isEmpty()) return Output.error("bad_args", "fof get ") val userArg = rest[0] Context.open(dataDir).use { ctx -> ctx.prepare() @@ -148,8 +154,8 @@ object WotCommand { // Amy's store lookup is suspending; can't do // it here. The dispatcher then falls through // to Phase 1 discovery for every author, which - // matches the old `amy wot sync` behaviour of - // always re-asking. A future optimisation + // matches the `fof sync` behaviour of always + // re-asking. A future optimisation // could pre-populate a `Map` before dispatch. null From ccd4677f5d7afe2c9dd02b9ab879e61049c82e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:45:25 +0000 Subject: [PATCH 187/206] fix: render p-tagged NIP-29 group replies on the Notifications tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reply to my message inside a joined NIP-29 relay group is a kind-9 ChatEvent that p-tags me (see ChannelNewMessageViewModel). It is already fetched at startup by filterGroupNotificationsToPubkey (scoped `#p`=me + `#h`=my groups on the group's host relay), but NotificationFeedFilter's `acceptableEvent` checks `kind in NOTIFICATION_KINDS` before the p-tag gate, and kind 9 (ChatEvent) was missing from that set — so those replies were dropped before ever reaching the p-tag check and never surfaced. Add ChatEvent.KIND to NOTIFICATION_KINDS. The existing gates handle the rest: my own messages are excluded (author != me), the reply p-tags me (isTaggedUser), and its `q`-tag to my message makes it pass tagsAnEventByUser in Selected mode too. Pin the behaviour with a tripwire test in NotificationKindsContractTest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK --- .../dal/NotificationFeedFilter.kt | 7 +++++++ .../dal/NotificationKindsContractTest.kt | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) 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..8f4cdc9eb2 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 @@ -81,6 +81,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 +137,12 @@ class NotificationFeedFilter( setOf( BadgeAwardEvent.KIND, ChannelMessageEvent.KIND, + // NIP-29 group chat (kind 9). A reply to my group message is a + // kind-9 that p-tags me (see ChannelNewMessageViewModel), fetched + // at startup by filterGroupNotificationsToPubkey. Without kind 9 + // here the acceptableEvent kind gate drops it before the p-tag + // check, so those replies never render on the Notifications tab. + ChatEvent.KIND, ChatMessageEvent.KIND, ChatMessageEncryptedFileHeaderEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt index 0e49022cf9..886bf711b9 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationKindsContractTest.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.dal import com.vitorpamplona.amethyst.commons.moderation.notifications.NotificationKinds import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import org.junit.Assert.assertTrue import org.junit.Test @@ -73,4 +74,23 @@ class NotificationKindsContractTest { unaccounted.isEmpty(), ) } + + /** + * A reply to my message inside a NIP-29 relay group is a kind-9 [ChatEvent] + * that p-tags me. It is fetched at startup by `filterGroupNotificationsToPubkey` + * (scoped `#p`=me + `#h`=my groups on the group's host relay), but the + * `acceptableEvent` gate first checks `kind in NOTIFICATION_KINDS`, so without + * kind 9 in the set the reply is dropped before the p-tag check and never + * surfaces on the Notifications tab. Pin its presence so it can't silently + * regress. + */ + @Test + fun `nip-29 group chat replies render on the Android notifications tab`() { + assertTrue( + "ChatEvent.KIND (9) is missing from NOTIFICATION_KINDS. NIP-29 group " + + "replies that p-tag the user would be dropped by the acceptableEvent " + + "kind gate before the p-tag check and never notify.", + ChatEvent.KIND in NotificationFeedFilter.NOTIFICATION_KINDS, + ) + } } From 86e29ed25a8d1861962d6976dfa36ad796211caa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 22:46:52 +0000 Subject: [PATCH 188/206] 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 189/206] 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 bf2b283cdc0b899c153b40894e1c359e46a9acfe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:06:18 +0000 Subject: [PATCH 190/206] fix: keep NIP-29 group reactions in the group so likes notify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "like" on a group message was built as a plain NIP-25 reaction — `e` (message) + `p` (author) + `k` — with no `h` tag. The recipient's only notification query that reaches the group's host relay, filterGroupNotificationsToPubkey, is scoped `#p`=them AND `#h`=their groups (kind 7 is already in GroupNotificationKinds), so a like with no `h` tag is never matched there. It would only surface if NIP-65 routing happened to drop it on one of the recipient's inbox relays — never for a host-relay-only group — so likes on group messages effectively never notified. Copy the target's `h` tag onto public reactions to group-scoped events, mirroring how kind-9 replies carry it. ReactionEvent.build gains an `initializer` (the API GroupScope's KDoc already documented); ReactionAction applies the group `h` tag for both the tracked and fire-and-forget paths. The like now lands on the host relay in-group and the existing kind-7 `#p`+`#h` query picks it up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VxpT7J4xt37EF5yJDw1htK --- .../model/nip25Reactions/ReactionAction.kt | 64 +++++++++++-------- .../nip25Reactions/ReactionActionTest.kt | 37 +++++++++++ .../quartz/nip25Reactions/ReactionEvent.kt | 5 ++ 3 files changed, 79 insertions(+), 27 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index ffeeaff9d3..6f07b1b5ad 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -24,12 +24,16 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User 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.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip29RelayGroups.groupId +import com.vitorpamplona.quartz.nip29RelayGroups.hTag import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag /** @@ -64,21 +68,39 @@ object ReactionAction { throw IllegalStateException("Cannot react publicly to a private rumor") } - // Handle custom emoji reactions (format: ":emoji_name:") - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - // Fallback to text if emoji decode fails - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } + return signer.sign(buildPublicReaction(eventHint, reaction)) + } - return signer.sign(template) + /** + * Builds a public reaction template for [eventHint], decoding a custom-emoji + * reaction when present and falling back to plain text otherwise. + * + * When the target is a NIP-29 group event (it carries an `h` tag), the + * reaction copies that `h` tag so the like stays scoped to the group and + * lands on the group's host relay — where the recipient's group-notification + * subscription (`#p`=them + `#h`=their groups, kind 7 included) can match it. + * Without the `h` tag the like is a plain kind-7 that the host-relay query + * never sees, so a reaction to someone's group message would only reach them + * on the off chance NIP-65 routing delivered it to one of their inbox relays + * — never for a host-relay-only group. This mirrors how kind-9 replies carry + * the `h` tag to be notifiable. + */ + private fun buildPublicReaction( + eventHint: EventHintBundle, + reaction: String, + ): EventTemplate { + val groupScope: TagArrayBuilder.() -> Unit = { + eventHint.event.groupId()?.let { hTag(it) } + } + + if (reaction.startsWith(":")) { + val emojiUrl = EmojiUrlTag.decode(reaction) + if (emojiUrl != null) { + return ReactionEvent.build(emojiUrl, eventHint, initializer = groupScope) + } + // Fallback to text if emoji decode fails + } + return ReactionEvent.build(reaction, eventHint, initializer = groupScope) } /** @@ -159,19 +181,7 @@ object ReactionAction { ) } else { // Public reaction - val template = - if (reaction.startsWith(":")) { - val emojiUrl = EmojiUrlTag.decode(reaction) - if (emojiUrl != null) { - ReactionEvent.build(emojiUrl, eventHint) - } else { - ReactionEvent.build(reaction, eventHint) - } - } else { - ReactionEvent.build(reaction, eventHint) - } - - onPublic(signer.sign(template)) + onPublic(signer.sign(buildPublicReaction(eventHint, reaction))) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt index c17e2aed36..bcf477442a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionActionTest.kt @@ -27,9 +27,12 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip29RelayGroups.hTag +import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.test.fail @@ -57,12 +60,46 @@ class ReactionActionTest { publicCalls++ assertTrue(reaction.sig.isNotEmpty(), "public reaction must be signed") assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == note.id }) + assertFalse( + reaction.tags.any { it.isNotEmpty() && it[0] == "h" }, + "a reaction to a non-group note must not carry an `h` tag", + ) }, onPrivate = { fail("reaction to a public note must not be gift-wrapped") }, ) assertEquals(1, publicCalls) } + @Test + fun reactionToRelayGroupMessage_carriesTheGroupHTag() = + runTest { + // A NIP-29 group chat message: a kind-9 ChatEvent scoped by `h`. + val groupId = "abcd1234" + val groupMessage = aliceSigner.sign(ChatEvent.build("gm") { hTag(groupId) }) + + var publicCalls = 0 + ReactionAction.reactToWithGroupSupport( + eventHint = EventHintBundle(groupMessage, null), + reaction = "+", + signer = bobSigner, + onPublic = { reaction -> + publicCalls++ + // Standard NIP-25 targeting … + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "e" && it[1] == groupMessage.id }) + assertTrue(reaction.tags.any { it.size >= 2 && it[0] == "p" && it[1] == aliceSigner.pubKey }) + // … plus the group `h` tag copied from the target, so the like + // stays in the group and the recipient's `#p`+`#h` host-relay + // notification query can match it. + assertTrue( + reaction.tags.any { it.size >= 2 && it[0] == "h" && it[1] == groupId }, + "a reaction to a NIP-29 group message must copy the group's `h` tag", + ) + }, + onPrivate = { fail("a public group message reaction must not be gift-wrapped") }, + ) + assertEquals(1, publicCalls) + } + @Test fun reactionToUnsealedRumor_isGiftWrappedToAllParticipants() = runTest { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index 69901c3965..e8b7e9c4a1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Immutable 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.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider @@ -88,6 +89,7 @@ class ReactionEvent( reaction: String, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction, createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -95,12 +97,14 @@ class ReactionEvent( } pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) + initializer() } fun build( reaction: EmojiUrlTag, reactedTo: EventHintBundle, createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, reaction.toContentEncode(), createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { @@ -109,6 +113,7 @@ class ReactionEvent( pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) emoji(reaction) + initializer() } } } From af8a95c44cbb24c3bc9409e0ce32f94ef9a4c960 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:11:42 +0000 Subject: [PATCH 191/206] 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 192/206] =?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 193/206] 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 194/206] 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 0acf5359c37789967098f9755275db0eaa6eca05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:37:47 +0000 Subject: [PATCH 195/206] fix: avoid StateFlow.value write in composition in NoteHeader preview Use featureSet.tryEmit(...) instead of assigning .value inside the @Composable preview, matching the pattern used elsewhere (AppSettingsScreen). Fixes the StateFlowValueCalledInComposition lint error. --- .../amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt index efac0ed3ed..c8b2a534ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteHeaderMarkersPreview.kt @@ -90,7 +90,8 @@ fun NoteHeaderFirstRowDensityPreview() { val nav = EmptyNav() // The jump-to-parent arrow only renders in complete UI mode. - accountViewModel.settings.uiSettingsFlow.featureSet.value = FeatureSetType.COMPLETE + accountViewModel.settings.uiSettingsFlow.featureSet + .tryEmit(FeatureSetType.COMPLETE) // Let DisplayLocation resolve the geohash synchronously from the cache. CachedReversedGeoLocations.locationNames.put(GEOHASH, "Belo Horizonte") From b6a238b71a4e5c8dac72a3dfeb13db8d0a49c389 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 23:50:27 +0000 Subject: [PATCH 196/206] 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 197/206] =?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 198/206] 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 b37a0ae3bb320d0c1365d0de3a7b010bccf27085 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:44:21 +0000 Subject: [PATCH 199/206] feat: add FAB to create a public chat on the Public Chats screen Adds a "+" FloatingActionButton to the Public Chats screen (reached from the left drawer) that navigates to the NIP-28 channel-creation flow (Route.ChannelMetadataEdit with no id), mirroring the create-public-chat action already offered by the Messages FAB. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01SDKFHhUqcDJvNEA7s7Qspm --- .../publicChats/NewPublicChatButton.kt | 52 +++++++++++++++++++ .../loggedIn/publicChats/PublicChatsScreen.kt | 6 +++ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 59 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt new file mode 100644 index 0000000000..4347f862ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/NewPublicChatButton.kt @@ -0,0 +1,52 @@ +/* + * 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.publicChats + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +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.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewPublicChatButton(nav: INav) { + FloatingActionButton( + onClick = { nav.nav(Route.ChannelMetadataEdit()) }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(id = R.string.new_public_chat), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt index ae177d56d9..88340294fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/publicChats/PublicChatsScreen.kt @@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold 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 @@ -73,6 +74,11 @@ fun PublicChatsScreen( } } }, + floatingButton = { + FabBottomBarPadded(nav) { + NewPublicChatButton(nav) + } + }, accountViewModel = accountViewModel, ) { RefresheableBox(publicChatsFeedContentState, true) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f38e025deb..67fe71c930 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -334,6 +334,7 @@ and thus chat messages disappear over time Public Chat + Create a new public chat MLS Group No messages yet Public Chat Metadata From a06cdd82fe6c06ffd0444f54c5c8f07da181eb8f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 01:54:32 +0000 Subject: [PATCH 200/206] 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 201/206] 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 202/206] 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 203/206] 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 204/206] 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 205/206] 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 206/206] 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