From 947ca3ebbb7afce02ff1d4c7e8080ff6dc7c3bd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:34:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(concord):=20account-level=20pin=20duties?= =?UTF-8?q?=20and=20pinned=20attachments=20rendered=20like=20the=20feed=20?= =?UTF-8?q?(CORD-04=20=C2=A77=20SHOULDs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/ConcordChannelScreen.kt | 7 +- .../commons/actions/ConcordPinnedRumor.kt | 32 +++++ .../amethyst/commons/model/Account.kt | 23 +++- .../commons/model/AccountConcordActions.kt | 40 +++++++ .../model/concord/ConcordPinDutyScheduler.kt | 96 +++++++++++++++ .../commons/actions/ConcordPinnedMediaTest.kt | 111 ++++++++++++++++++ .../concord/ConcordPinDutySchedulerTest.kt | 71 +++++++++++ .../concord/ConcordPinnedMessages.kt | 80 +++++++------ .../2026-09-29-concord-spec-conformance.md | 2 +- 9 files changed, 417 insertions(+), 45 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedRumor.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutyScheduler.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutySchedulerTest.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 f8e2c0031d..c416ed5f61 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 @@ -86,7 +86,6 @@ import com.vitorpamplona.amethyst.commons.ui.insets.imePaddingSafe import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.feed.types.concordTimerText -import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinDuties import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedButton import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordPinnedMessagesSheet import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelSubscription @@ -186,10 +185,9 @@ fun ConcordChannelScreen( newMessageModel.init(accountViewModel) newMessageModel.load(communityId, channelId) - // CORD-04 §7 Pins: the header's entry point, the sheet it opens, the jump it requests, and the - // delayed duty writes (deletion omission / Edit refresh) a PIN_MESSAGES holder owes. + // CORD-04 §7 Pins: the header's entry point, the sheet it opens and the jump it requests. The + // delayed duty writes a PIN_MESSAGES holder owes run from the account (scheduleConcordPinDuties). val pins by rememberConcordChannelPins(communityId, channelId, accountViewModel) - ConcordPinDuties(communityId, channelId, pins, accountViewModel) var showPins by remember { mutableStateOf(false) } val jumpToNoteId = remember { mutableStateOf(null) } pins?.let { current -> @@ -199,6 +197,7 @@ fun ConcordChannelScreen( channelId = channelId, pins = current, accountViewModel = accountViewModel, + nav = nav, onJumpToMessage = { jumpToNoteId.value = it }, onDismiss = { showPins = false }, ) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedRumor.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedRumor.kt new file mode 100644 index 0000000000..d86a4a492b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedRumor.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.commons.actions + +import com.vitorpamplona.quartz.nip01Core.core.Event + +/** + * This pin as the rumor the chat feed would render (CORD-04 §7): the recomputed id, author, kind and + * the original's tags — so its NIP-92 `imeta` attachments, encrypted ones included, come along — with + * the newest words this client can show ([ConcordPinnedMessage.content]). Unsigned: a rumor never is. + * A pin proves its message without this account ever having held it, so this is the only source of + * the attachment keys for such a pin. + */ +fun ConcordPinnedMessage.toRumor(): Event = Event(pin.rumorId, pin.author, pin.createdAt, pin.kind, pin.tags, content, "") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 135c390b1a..0f85ef314f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -190,6 +190,7 @@ import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply import com.vitorpamplona.quartz.buzz.threading.buzzThreadRoot import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord03Channels.ConcordTimerNoticeEvent import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent @@ -360,6 +361,7 @@ import com.vitorpamplona.quartz.utils.containsAny import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.IO import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -367,7 +369,10 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -800,7 +805,7 @@ class Account( * 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) { + internal fun registerConcordEncryptedImages(rumor: Event) { val images = ChannelChat.encryptedImagesOf(rumor) if (images.isEmpty()) return images.forEach { img -> @@ -4198,6 +4203,22 @@ class Account( } } + // CORD-04 §7: a PIN_MESSAGES holder owes keyless readers the deletion omission and the Edit + // refresh whether or not the channel is open, so the delayed pin duties run from the account — + // on every structural tick (a new Pin List head, a fold) and whenever a delete or an Edit lands. + // The scheduler itself waits 3–15 s, keeps one duty per channel and one attempt per debt. + scope.launch { + @OptIn(FlowPreview::class) + merge( + concordSessions.revision.map { }, + cache.live.newEventBundles + .filter { notes -> notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } } + .map { }, + ).sample(1000).collect { + runCatching { concord.scheduleConcordPinDuties(scope) }.onFailure { Log.w("Concord", "pin duty scheduling failed", it) } + } + } + // CORD-08 §3: purge disappearing Concord messages when they expire. Sleeps until the earliest // deadline any joined community holds and never wakes while nothing carries one, so a // community without a timer costs nothing. A new earlier deadline restarts the wait. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index b61718273d..191dfdc8e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -27,10 +27,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite +import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage import com.vitorpamplona.amethyst.commons.actions.ConcordPinning import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.amethyst.commons.actions.toRumor import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note @@ -41,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteInbox import com.vitorpamplona.amethyst.commons.model.concord.ConcordDirectInviteView import com.vitorpamplona.amethyst.commons.model.concord.ConcordKickNotice +import com.vitorpamplona.amethyst.commons.model.concord.ConcordPinDutyScheduler import com.vitorpamplona.amethyst.commons.model.concord.DirectInviteAcceptPlan import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute import com.vitorpamplona.amethyst.commons.util.ConcurrentSet @@ -107,6 +110,7 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -1705,6 +1709,18 @@ class AccountConcordActions( ) } + /** + * [pinned] as the rumor the chat feed would render — its newest proven words over the original's + * tags (the NIP-92 `imeta` attachments), with the encrypted attachments' keys registered so the + * shared media pipeline fetches and decrypts them exactly as for a feed message. A pin proves its + * message without this account ever having held it, so the keys come from the proof itself. + */ + fun concordPinnedRumor(pinned: ConcordPinnedMessage): Event { + val rumor = pinned.toRumor() + account.registerConcordEncryptedImages(rumor) + return rumor + } + /** The author's newest Concord Edit this account holds for [rumorId], or null. */ private fun heldConcordEdit( rumorId: HexKey, @@ -1825,6 +1841,30 @@ class AccountConcordActions( } } + private val concordPinDuties = ConcordPinDutyScheduler() + + /** + * Schedules, in [scope], the delayed pin duties (CORD-04 §7) this account owes in every joined + * community where it may write pins: for each Channel with a Pin List head whose read owes a + * republish, one [settleConcordPins] after the 3–15 s random wait ([ConcordPinDutyScheduler]). + * Called by the account on the Concord revision tick and when a delete or an Edit lands — the + * duties no longer depend on the channel screen being open. + */ + internal fun scheduleConcordPinDuties(scope: CoroutineScope) { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val communityId = session.entry.id + if (!canPinConcord(communityId)) continue + for (channelIdHex in session.pinHeads.value.keys) { + val debt = ConcordPinDutyScheduler.debtOf(concordChannelPins(communityId, channelIdHex)) + concordPinDuties.schedule(scope, ConcordPinDutyScheduler.keyOf(communityId, channelIdHex), debt) { + runCatching { settleConcordPins(communityId, channelIdHex) } + .onFailure { Log.w("Concord", "pin duty for $channelIdHex in $communityId failed", it) } + } + } + } + } + // ── 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutyScheduler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutyScheduler.kt new file mode 100644 index 0000000000..771e3936f4 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutyScheduler.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.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins +import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Runs the delayed Pin List duties a PIN_MESSAGES holder owes keyless readers (CORD-04 §7) — the + * deletion omission and the Edit refresh — from the account, not from an open channel screen, so a + * debt is settled whether or not anyone is looking at the channel. + * + * Rate-limited the way the spec asks: each duty waits a short random delay + * ([ConcordPinning.dutyDelayMs], 3–15 s) and the caller's settle re-reads before publishing, so + * simultaneous curators collapse to one publisher and a burst of deletes or edits costs one write; + * at most one duty per channel is in flight; and each distinct debt is attempted **once**, so a + * write that keeps failing never spins. A new debt (another delete, a newer Edit) is a new attempt. + */ +class ConcordPinDutyScheduler( + private val delayMs: () -> Long = { ConcordPinning.dutyDelayMs() }, +) { + private val lock = KmpLock() + + // Channel key -> the debt last attempted there. + private val attempted = HashMap() + + // Channel key -> its duty in flight. + private val inFlight = HashMap() + + /** + * Schedules [settle] in [scope] for the channel [key] when [debt] is non-null, was not attempted + * yet, and no duty for [key] is in flight. Returns whether it scheduled one. + */ + fun schedule( + scope: CoroutineScope, + key: String, + debt: String?, + settle: suspend () -> Unit, + ): Boolean { + if (debt == null) return false + return lock.withLock { + if (attempted[key] == debt || inFlight[key]?.isActive == true) return@withLock false + attempted[key] = debt + inFlight[key] = + scope.launch { + delay(delayMs()) + try { + settle() + } finally { + lock.withLock { inFlight.remove(key) } + } + } + true + } + } + + companion object { + /** The identity of what [pins] owes (its erased entries and the Edits to attach), or null when nothing. */ + fun debtOf(pins: ConcordChannelPins?): String? { + if (pins == null || !pins.owesRepublish) return null + return (pins.killed.map { "d" + it.rumorId } + pins.pins.mapNotNull { p -> p.newerEdit?.let { "e" + it.rumorId } }) + .sorted() + .joinToString("|") + } + + /** The scheduler key of one channel. */ + fun keyOf( + communityId: String, + channelIdHex: String, + ): String = "$communityId|$channelIdHex" + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt new file mode 100644 index 0000000000..9705961af0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinnedMediaTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls +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.Event +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.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** + * A pinned message carrying an encrypted attachment renders like the feed message it pins (CORD-04 §7 + * SHOULD): the rumor rebuilt from the verified proof keeps the `imeta` tags — so the attachment's + * decryption key reaches the media pipeline even when this account never held the message — and an + * attachment-only message still gets its URL as text, as the feed gives it. + */ +class ConcordPinnedMediaTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + + @Test + fun aPinnedImageKeepsItsEncryptedAttachment() = + 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, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + val rumors = mutableListOf() + val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> rumors += rumor } + community.genesisWraps.forEach { session.ingest(it) } + val general = community.generalChannelIdHex + val plane = assertNotNull(session.currentChannelPlane(general)) + + val url = "https://blossom.example/ciphertext.bin" + val cipher = AESGCM(ByteArray(32) { 0x11 }, ByteArray(16) { 0x22 }) + val imeta = ChannelChat.encryptedImageImeta(url, "image/jpeg", "800x600", null, cipher, "aa".repeat(32)) + // An attachment-only message: empty words, the image only in its imeta (Armada allows it). + session.ingest(ConcordActions.buildChannelImageMessage(alice, plane.key, general, plane.epoch, "", listOf(imeta), 10L)) + val message = rumors.last() + + fun ctx(pins: ConcordChannelPins) = + ConcordPinContext( + actor = owner, + controlPlane = session.controlPlaneKeys(), + communityId = community.communityId, + owner = community.ownerPubKey, + current = session.controlEditions(), + channelIdHex = general, + channelIsPrivate = false, + currentPlane = plane, + pins = pins, + authorized = true, + ) + + val evidence = ConcordPinEvidence(rumors) + val before = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit)) + val write = ConcordPinning.pin(ctx(before), assertNotNull(session.pinSource(general, message.id)), 11L) + session.ingest(assertNotNull(write.wrap)) + + val pinned = assertNotNull(session.readPins(general, evidence::isKilled, evidence::newestEdit)).pins.single() + val rumor = pinned.toRumor() + assertEquals(message.id, rumor.id) + assertEquals(alice.pubKey, rumor.pubKey) + + val attachment = ChannelChat.encryptedImagesOf(rumor).single() + assertEquals(url, attachment.url) + assertContentEquals(cipher.keyBytes, attachment.key) + assertContentEquals(cipher.nonce, attachment.nonce) + // The words carry the ciphertext URL (Armada's assembly), so the shared renderer shows it. + assertEquals(url, rumor.content) + // A client that sent only the imeta (empty words) still renders it: the feed's fallback. + assertEquals(url, appendMissingImetaUrls("", rumor)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutySchedulerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutySchedulerTest.kt new file mode 100644 index 0000000000..6110e7e842 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPinDutySchedulerTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.ConcordChannelPins +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +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 + +/** + * The account-level pin duty scheduler (CORD-04 §7): a duty waits its random delay, a channel runs + * one duty at a time, and a debt is attempted once — so a burst of ticks costs one write and a failing + * write never spins — while a new debt is a new attempt. + */ +class ConcordPinDutySchedulerTest { + @Test + fun oneAttemptPerDebtAndOneDutyPerChannel() = + runTest { + val scheduler = ConcordPinDutyScheduler(delayMs = { 5_000L }) + var runs = 0 + + assertTrue(scheduler.schedule(this, "c|a", "d1") { runs++ }) + assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "the same debt twice") + assertFalse(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a second duty while one is in flight") + assertTrue(scheduler.schedule(this, "c|b", "d1") { runs++ }, "another channel is independent") + + advanceTimeBy(4_999) + runCurrent() + assertEquals(0, runs, "a duty waits its delay before settling") + advanceUntilIdle() + assertEquals(2, runs) + + assertFalse(scheduler.schedule(this, "c|a", "d1") { runs++ }, "an attempted debt is never respun") + assertTrue(scheduler.schedule(this, "c|a", "d2") { runs++ }, "a new debt is a new attempt") + advanceUntilIdle() + assertEquals(3, runs) + } + + @Test + fun nothingOwedSchedulesNothing() = + runTest { + val scheduler = ConcordPinDutyScheduler(delayMs = { 0L }) + assertFalse(scheduler.schedule(this, "c|a", null) { error("must not run") }) + assertNull(ConcordPinDutyScheduler.debtOf(null)) + assertNull(ConcordPinDutyScheduler.debtOf(ConcordChannelPins.none("aa".repeat(32)))) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt index 9bf91f3b60..fd31299ada 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordPinnedMessages.kt @@ -41,9 +41,9 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -53,11 +53,12 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage -import com.vitorpamplona.amethyst.commons.actions.ConcordPinning import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache +import com.vitorpamplona.amethyst.commons.model.nip92IMeta.appendMissingImetaUrls +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.concord_pinned_budget @@ -68,6 +69,8 @@ import com.vitorpamplona.amethyst.commons.resources.concord_pinned_unavailable import com.vitorpamplona.amethyst.commons.resources.message_edited import com.vitorpamplona.amethyst.commons.resources.relay_group_pinned_content_description import com.vitorpamplona.amethyst.commons.resources.relay_group_unpin_message +import com.vitorpamplona.amethyst.commons.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.note.timeAgoNoDot import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText @@ -77,7 +80,6 @@ import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -108,34 +110,6 @@ fun rememberConcordChannelPins( } } -/** - * The deletion omission and the Edit refresh a PIN_MESSAGES holder owes keyless readers (§7), run - * the way the spec asks: after a short random wait, re-read, and publish only if still owed — so - * simultaneous curators collapse to one publisher and a burst of edits costs one write. One attempt - * per distinct debt, so a failure never spins. - */ -@Composable -fun ConcordPinDuties( - communityId: String, - channelId: String, - pins: ConcordChannelPins?, - accountViewModel: AccountViewModel, -) { - val debt = - remember(pins) { - pins - ?.takeIf { it.owesRepublish } - ?.let { p -> (p.killed.map { "d" + it.rumorId } + p.pins.mapNotNull { it.newerEdit?.let { e -> "e" + e.rumorId } }).sorted().joinToString("|") } - } ?: return - val attempted = remember(communityId, channelId) { HashSet() } - LaunchedEffect(communityId, channelId, debt) { - if (debt in attempted || !accountViewModel.account.concord.canPinConcord(communityId)) return@LaunchedEffect - delay(ConcordPinning.dutyDelayMs()) - attempted.add(debt) - accountViewModel.launchSigner { accountViewModel.account.concord.settleConcordPins(communityId, channelId) } - } -} - /** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */ @Composable fun ConcordPinnedButton( @@ -156,8 +130,9 @@ fun ConcordPinnedButton( /** * The pinned-messages sheet: each verified pin with its author, time and words (marked edited when - * revised), an "unavailable" notice when the list is sealed under a key this account never held, - * a jump to the message when it resolves locally, and Unpin for those who may write pins. + * revised) rendered like the chat feed renders the message — links, mentions and its `imeta` + * attachments included — an "unavailable" notice when the list is sealed under a key this account + * never held, a jump to the message when it resolves locally, and Unpin for those who may write pins. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -166,6 +141,7 @@ fun ConcordPinnedMessagesSheet( channelId: String, pins: ConcordChannelPins, accountViewModel: AccountViewModel, + nav: INav, onJumpToMessage: (HexKey) -> Unit, onDismiss: () -> Unit, ) { @@ -220,6 +196,7 @@ fun ConcordPinnedMessagesSheet( PinnedRow( pinned = pinned, accountViewModel = accountViewModel, + nav = nav, onClick = if (jumpable) { { @@ -257,6 +234,7 @@ private fun PinNotice( private fun PinnedRow( pinned: ConcordPinnedMessage, accountViewModel: AccountViewModel, + nav: INav, onClick: (() -> Unit)?, onUnpin: (() -> Unit)?, ) { @@ -292,12 +270,7 @@ private fun PinnedRow( ) } } - Text( - text = pinned.content, - style = MaterialTheme.typography.bodyMedium, - maxLines = 6, - overflow = TextOverflow.Ellipsis, - ) + PinnedContent(pinned, accountViewModel, nav) } if (onUnpin != null) { IconButton(onClick = onUnpin) { @@ -307,6 +280,35 @@ private fun PinnedRow( } } +/** + * The pinned message's words and attachments through the chat feed's own pipeline: the rumor rebuilt + * from the proof (its encrypted attachments' keys registered), an attachment-only message given its + * `imeta` URLs as text exactly as the feed does, and the shared rich-text viewer rendering the media. + */ +@Composable +private fun PinnedContent( + pinned: ConcordPinnedMessage, + accountViewModel: AccountViewModel, + nav: INav, +) { + val rumor = remember(pinned) { accountViewModel.account.concord.concordPinnedRumor(pinned) } + val tags = remember(rumor) { rumor.tags.toImmutableListOfLists() } + val content = remember(rumor) { appendMissingImetaUrls(rumor.content, rumor) } + val background = MaterialTheme.colorScheme.surface + val backgroundColor = remember(background) { mutableStateOf(background) } + TranslatableRichTextViewer( + content = content, + canPreview = true, + quotesLeft = 0, + tags = tags, + backgroundColor = backgroundColor, + id = pinned.rumorId, + authorPubKey = pinned.author, + accountViewModel = accountViewModel, + nav = nav, + ) +} + /** [hex]'s best display name, reactively, falling back to a short hex. */ @Composable private fun rememberPinAuthorName( diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 324ba8bb49..0ca24bf133 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -82,7 +82,7 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| -| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: the duties run only while the channel screen is open (no background scheduler); a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List; pinned attachments render as text only | +| F1 | 04 §7 | Pins | **fixed** — commons `ConcordPinning` reads each Channel's Pin List off the session fold (`pinHeads`, gated on PIN_MESSAGES + `vac`, with the fold's floors), opens the sealed form with the held key of its epoch (`sealedUnavailable` kept distinct from empty), verifies entries through a per-entry-identity cache, hides entries killed by the author's held kind 5 and marks entries behind a newer held Edit as edited; pin/unpin reopen the message's original wrap (session rumor→wrap index) and write the next edition over the head read, in the channel's folded form (a private-era sealed list is never re-formed public), withheld when unreadable, refused past 25 entries / 32,768 bytes; deleting your own pinned message publishes the omission at once, and an open channel runs the delayed (3–15 s) re-read-then-publish duty for other holders' omissions and the Edit refresh. App: Pin/Unpin in the message sheet, header pin badge + pinned sheet (author, time, edited, unavailable, jump), budget line; `amy concord pins/pin/unpin`. Also fixed `DeletionIndex.DeletionRequest.compareTo` (compared the pubkey with itself, so any author's kind 5 matched on JVM/Android). Open SHOULDs: a Rotator does not republish under the new key after a private-channel rekey, and a Banlist revert is not re-healed; compaction does not omit a deleted Channel's Pin List. **Fixed since** (features batch): the delayed duties run from the account, not the screen — `ConcordPinDutyScheduler` + `AccountConcordActions.scheduleConcordPinDuties`, fired on every Concord revision tick and whenever a delete or an Edit lands (sampled 1 s), for every community where we may write pins and every Channel with a Pin List head; each duty waits the 3–15 s random delay, one per channel in flight, one attempt per distinct debt, and `settleConcordPins` re-reads before publishing (the screen's `ConcordPinDuties` composable is gone); pinned messages render through the feed's pipeline — the rumor rebuilt from the proof (`ConcordPinnedMessage.toRumor`, keeping the original's `imeta` tags) registers its encrypted attachments' keys and goes through `appendMissingImetaUrls` + the shared `TranslatableRichTextViewer`, so images (and links, mentions) show in the pinned sheet even for a message this account never held | | F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | **fixed** — every durable Chat rumor (9/1111/7/3302, image variants) signs `created_at + timer` from the send-time fold and its wrap repeats it (`ConcordStreamEnvelope.wrap(outerTags)`, random `p` kept; never on 5/1740/typing); expired rumors refused at ingest (`openChannelRumor`, session, rumor sink), hidden in feed/preview/unread (`Account.isAcceptable`), and purged from LocalCache + wrap note + session buffer by a sweep scheduled on the earliest deadline (`ConcordSessionManager.nextExpiry`); typed `ConcordTimerNoticeEvent` posted per held channel after a timer change and rendered as a system row only for MANAGE_METADATA authors; timer picker in the edit screen + composer indicator; `amy concord timer`, `send` tags, `read` filters | | F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | | F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | **fixed** — `ConcordBrokerToken.buildAuthEvent` signs a fresh `["nonce", <64 hex>]` after `u`/`method` (Armada `signAvGrant`'s tag, order and 32-byte width), so two members' same-second grants never share an id; `VoicePresenceInfo` carries the CORD-02 §4 `ms` basis + rumor id, `parse` drops a bad verb, an identity-less `joined` or a malformed `ms`, and `VoicePresence.fold`/`latestPerAuthor` take each author's latest presence (ms, lower rumor id on a tie, as Armada `foldVoicePresence`) before staleness and the one-claimant identity check; `joined`/`left` stamp `ms`. No app caller yet (the voice UI is not built) |