feat(concord): account-level pin duties and pinned attachments rendered like the feed (CORD-04 §7 SHOULDs)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 20:34:41 +00:00
parent e14f748b82
commit 947ca3ebbb
9 changed files with 417 additions and 45 deletions
@@ -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<String?>(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 },
)
@@ -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, "")
@@ -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.
@@ -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
@@ -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<String, String>()
// Channel key -> its duty in flight.
private val inFlight = HashMap<String, Job>()
/**
* 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"
}
}
@@ -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<Event>()
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))
}
}
@@ -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))))
}
}
@@ -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<String>() }
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(
@@ -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) |