mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+3
-4
@@ -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 },
|
||||
)
|
||||
|
||||
+32
@@ -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.
|
||||
|
||||
+40
@@ -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
|
||||
|
||||
+96
@@ -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"
|
||||
}
|
||||
}
|
||||
+111
@@ -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))
|
||||
}
|
||||
}
|
||||
+71
@@ -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
-39
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user