From 2230358c10fe24da0b87466ed00a6e7f1b03f194 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:35:50 +0000 Subject: [PATCH] fix(concord): pinned messages follow the session, expire on time, and hide banned authors - P4: rememberConcordChannelPins and ConcordTimerIndicator re-resolve the session on every session-set change (null at first composition, replaced on Refounding). - P5: the pins re-read at the soonest pinned message's NIP-40 deadline. - P6: Pin List reads memoized by head rumor id (ConcordPinVerifier.readList); the sealed form is reported by ConcordPins.read (no second parse); the evidence trigger only fires for deletes/Edits naming a pinned message; the action sheet's pin state is read off the main thread; an unfinished fold never shows "no pins". - P10: the pinned sheet and badge leave out banned authors and muted/blocked users. - P11: pinning a disappearing message asks first; `amy concord pin` needs --force. - P12: `amy concord pins` hides expired pinned messages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../chats/feed/ChatMessageActionSheet.kt | 32 ++++- .../concord/ConcordChannelScreen.kt | 8 +- cli/README.md | 4 +- .../cli/commands/ConcordPinCommands.kt | 11 +- .../commons/actions/ConcordPinning.kt | 57 +++++++- .../composeResources/values/strings.xml | 3 + .../concord/ConcordPinnedMessages.kt | 125 +++++++++++++++--- .../concord/cord04Roles/pins/ConcordPins.kt | 18 ++- .../cord04Roles/pins/ConcordPinsTest.kt | 6 + 9 files changed, 234 insertions(+), 30 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 2248fe8e99..4b1fd7f9f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -44,6 +44,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -92,6 +93,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.elements.NoteActionHandlers import com.vitorpamplona.amethyst.commons.ui.note.elements.ShareOptionsBottomSheet import com.vitorpamplona.amethyst.commons.ui.note.elements.noteActionSections import com.vitorpamplona.amethyst.commons.ui.note.elements.observeBookmarksFollowsAndAccount +import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.publicChannels.concord.ConcordExpiringPinDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.navigateToReloadMint import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -108,12 +110,15 @@ import com.vitorpamplona.amethyst.ui.note.observeZapRailCapability import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlin.uuid.ExperimentalUuidApi // null amount = open the on-chain dialog with no prefill. @@ -379,16 +384,37 @@ fun ChatMessageActionSheet( // Concord (CORD-04 §7): pin/unpin into the channel's Pin List. Only offered to a // PIN_MESSAGES holder who can write the Control Plane (null otherwise). - val concordPinned = remember(note) { accountViewModel.account.concord.concordPinState(note) } + // Read off the main thread: it verifies the channel's whole Pin List. + val concordPinState by produceState(null, note) { + value = withContext(Dispatchers.Default) { accountViewModel.account.concord.concordPinState(note) } + } + val concordPinned = concordPinState if (concordPinned != null && !note.isDraft()) { + var confirmExpiringPin by remember(note) { mutableStateOf(false) } SectionDivider() TileRow { val label = if (concordPinned) Res.string.relay_group_unpin_message else Res.string.relay_group_pin_message ActionTile(MaterialSymbols.PushPin, stringRes(label)) { - accountViewModel.toggleConcordPin(note) - onDismiss() + // Pinning a disappearing message (CORD-08) keeps its words past the timer: ask first. + val expires = note.event?.let { ConcordDisappearing.expirationOf(it) } != null + if (!concordPinned && expires) { + confirmExpiringPin = true + } else { + accountViewModel.toggleConcordPin(note) + onDismiss() + } } } + if (confirmExpiringPin) { + ConcordExpiringPinDialog( + onConfirm = { + confirmExpiringPin = false + accountViewModel.toggleConcordPin(note) + onDismiss() + }, + onDismiss = { confirmExpiringPin = false }, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index f8e2c0031d..de706f63d4 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 @@ -208,7 +208,7 @@ fun ConcordChannelScreen( Scaffold( topBar = { TopAppBar( - actions = { ConcordPinnedButton(pins) { showPins = true } }, + actions = { ConcordPinnedButton(communityId, pins, accountViewModel) { showPins = true } }, title = { Column { Text(channel.toBestDisplayName(), maxLines = 1) @@ -332,7 +332,11 @@ private fun ConcordTimerIndicator( communityId: String, accountViewModel: AccountViewModel, ) { - val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } ?: return + // Re-resolved on every session-set change: the session may not exist yet at first composition, and + // a Refounding replaces it (a captured one would keep reading the dead epoch's fold). + val sessions = accountViewModel.account.concordSessions + val revision by sessions.revision.collectAsStateWithLifecycle() + val session = remember(communityId, revision) { sessions.sessionFor(communityId) } ?: return val state by session.state.collectAsStateWithLifecycle() val secs = state?.metadata?.messageExpirationSecs() ?: return Text( diff --git a/cli/README.md b/cli/README.md index 8bc626321f..96d43a34f8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -693,8 +693,8 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). | | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). | -| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). | -| `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. | +| `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). Expired (CORD-08) pinned messages are hidden like deleted ones. | +| `amy concord pin COMMUNITY CHANNEL RUMOR_ID [--force]` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). A disappearing message (one carrying a CORD-08 `expiration`) is refused with `expiring_message` unless `--force`: the pin would keep its words past the timer. Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. | | `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). | | `amy concord timer COMMUNITY [off\|SECONDS\|1d\|1w\|30d\|90d\|1y]` | CORD-08 disappearing messages. No value: print the folded timer (`0` = off). With one: publish the metadata edition (MANAGE_METADATA) and a kind-1740 notice into every channel whose key we hold. While a timer is set, `send` signs a NIP-40 `expiration` into the rumor and repeats it on the wrap; `read` drops expired messages. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt index bbbf4e946c..a1581ecd21 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPinCommands.kt @@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite import com.vitorpamplona.amethyst.commons.actions.ConcordPinning import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -91,7 +93,8 @@ object ConcordPinCommands { ?.key ?.conversationKey }, - isKilled = view.evidence::isKilled, + // CORD-08 §3: an expired message never shows, pinned or not (the proof stays valid, the rumor says it is gone). + isKilled = { view.evidence.isKilled(it) || it.tags.isExpirationBefore(TimeUtils.now()) }, newestEdit = view.evidence::newestEdit, ) } @@ -168,6 +171,7 @@ object ConcordPinCommands { val handle = args.positional(0, "community") val channelRef = args.positional(1, "channel") val rumorId = args.positional(2, "rumor_id").lowercase() + val force = pin && args.bool("force") args.rejectUnknown() if (!HEX64.matches(rumorId)) return Output.error("bad_args", "RUMOR_ID must be a 64-char hex rumor id") val stored = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) @@ -202,9 +206,14 @@ object ConcordPinCommands { if (pin) { val refused = ConcordPinning.refusal(pinCtx) val source = if (refused == null) ConcordPinning.sourceFrom(view.wraps, view.planes, rumorId) else null + val expiresAt = source?.let { ConcordDisappearing.expirationOf(it.opened.rumor) } when { refused != null -> ConcordPinWrite(refused) source == null -> ConcordPinWrite(ConcordPinOutcome.MESSAGE_UNAVAILABLE) + // A pin carries the message's words in its proof: pinning a disappearing message + // makes it outlive its timer (CORD-08), so that takes an explicit --force. + expiresAt != null && !force -> + return Output.error("expiring_message", "that message disappears at $expiresAt (CORD-08) and a pin would keep its words past the timer; pass --force to pin it anyway") else -> ConcordPinning.pin(pinCtx, source, TimeUtils.now()) } } else { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt index 15c14cc88b..e167d1cb7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPinning.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.serialization.json.JsonObject import kotlin.random.Random @@ -105,6 +106,15 @@ class ConcordChannelPins( /** True when the head owes keyless readers a republish: an erased entry, or a newer Edit to attach. */ val owesRepublish: Boolean get() = killed.isNotEmpty() || pins.any { it.newerEdit != null } + /** Every rumor id the list carries, shown or erased — what a delete or an Edit must name to change this read. */ + val rumorIds: Set by lazy { (alive + killed).mapTo(HashSet()) { it.rumorId } } + + /** + * The soonest NIP-40 deadline (unix seconds) after [now] among the shown pins, or null: when this + * read goes stale, since an expired message leaves the list (CORD-08 §3). + */ + fun nextExpiry(now: Long): Long? = alive.mapNotNull { pin -> pin.tags.firstNotNullOfOrNull(ExpirationTag::parse) }.filter { it > now }.minOrNull() + companion object { fun none( channelIdHex: HexKey, @@ -144,6 +154,38 @@ class ConcordPinVerifier( } return verdict } + + private val lists = LinkedHashMap() + + /** List parses (and sealed-form decrypts) actually performed (cache misses) — for tests. */ + var listReads: Int = 0 + private set + + /** + * [head]'s content read as a Pin List, memoized by the head's rumor id: an edition's bytes never + * change, so re-reading the pins on every trigger (a fold, a delete landing, an expiry) parses and + * decrypts the list once. A read that found the list sealed under a key not held is not cached, + * so the key arriving later (a Private Channel key delivered on grant) opens it. + */ + fun readList( + head: ControlEdition, + unsealKey: (epoch: Long) -> ByteArray?, + ): ConcordPins.PinListRead { + lock.withLock { lists[head.rumorId] }?.let { return it } + val read = ConcordPins.read(head.content, unsealKey) + lock.withLock { + listReads++ + if (!read.sealedUnavailable) { + lists[head.rumorId] = read + while (lists.size > MAX_LISTS) lists.remove(lists.keys.first()) + } + } + return read + } + + companion object { + private const val MAX_LISTS = 64 + } } /** The proof material for pinning one opened message: its original seal and the plane key of its epoch. */ @@ -297,7 +339,7 @@ object ConcordPinning { complete: Boolean = true, ): ConcordChannelPins { if (head == null) return ConcordChannelPins.none(channelIdHex, complete) - val read = ConcordPins.read(head.content, unsealKey) + val read = verifier.readList(head, unsealKey) val alive = ArrayList() val killed = ArrayList() var invalid = 0 @@ -326,12 +368,23 @@ object ConcordPinning { pins = shown, sealedUnavailable = read.sealedUnavailable, violating = read.violating, - sealedForm = ConcordPins.isSealedForm(head.content), + sealedForm = read.sealedForm, invalidEntries = invalid, complete = complete, ) } + /** + * [pins]' shown entries a reader displays: an entry by a [isBanned] author (the community declines + * to show a banned member's posts, CORD-04 §4) or by someone the reader [isHidden]s (mutes or + * blocks) is left out. Display only — the list itself, and every write built from it, is untouched. + */ + fun visible( + pins: ConcordChannelPins, + isBanned: (HexKey) -> Boolean, + isHidden: (HexKey) -> Boolean, + ): List = pins.pins.filterNot { isBanned(it.author) || isHidden(it.author) } + /** True when [edit] is newer than whatever Edit [pin]'s proof already carries. */ private fun isNewer( edit: ConcordLocalEdit, diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a639d91b72..5e1100e231 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -619,6 +619,9 @@ This channel's pins are sealed under a key you don't hold, so they can't be shown here, and pinning is paused until they can be read. %1$d of %2$d pins · %3$d% of the size budget used Tap a pin to jump to it + Pin a disappearing message? + This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself. + Pin anyway Pins The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see. This channel already has 25 pins. Unpin one first. 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..01e9d5d0cf 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 @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,6 +40,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,6 +53,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPins import com.vitorpamplona.amethyst.commons.actions.ConcordPinnedMessage import com.vitorpamplona.amethyst.commons.actions.ConcordPinning @@ -60,6 +63,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.cancel +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_body +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_confirm +import com.vitorpamplona.amethyst.commons.resources.concord_pin_expiring_title import com.vitorpamplona.amethyst.commons.resources.concord_pinned_budget import com.vitorpamplona.amethyst.commons.resources.concord_pinned_empty import com.vitorpamplona.amethyst.commons.resources.concord_pinned_open_hint @@ -76,8 +83,13 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.pins.ConcordPins import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge @@ -86,9 +98,15 @@ import org.jetbrains.compose.resources.StringResource /** * [channelId]'s verified pins (CORD-04 §7), re-read whenever the Control Plane seats a new Pin List - * head, the fold changes, or a delete / Edit lands in the cache (a held delete hides its entry at - * once; a held newer Edit marks it edited). Null until the community has folded the channel. + * head, the fold changes or finishes draining, a delete / Edit naming a pinned message lands in the + * cache (a held delete hides its entry at once; a held newer Edit marks it edited), or a pinned + * message's disappearing-message deadline passes (CORD-08 §3). Null until the community has folded + * the channel. + * + * The session is looked up again on every session-set change: it may not exist at first + * composition, and a Refounding replaces it — a captured one would read the dead epoch forever. */ +@OptIn(ExperimentalCoroutinesApi::class) @Composable fun rememberConcordChannelPins( communityId: String, @@ -97,17 +115,85 @@ fun rememberConcordChannelPins( ): State { val account = accountViewModel.account return produceState(null, account, communityId, channelId) { - val session = account.concordSessions.sessionFor(communityId) ?: return@produceState - val evidence = - account.cache.live.newEventBundles.filter { notes -> - notes.any { it.event is DeletionRequestEvent || it.event is ConcordChatEditEvent } + account.concordSessions.revision + .map { account.concordSessions.sessionFor(communityId) } + .distinctUntilChanged { a, b -> a === b } + .collectLatest { session -> + if (session == null) { + value = null + return@collectLatest + } + // Only deletes and Edits that name a message this list carries can change the read. + val evidence = + account.cache.live.newEventBundles.filter { notes -> + val carried = value?.rumorIds ?: return@filter true + notes.any { note -> + val event = note.event + (event is DeletionRequestEvent || event is ConcordChatEditEvent) && + event.tags.any { it.size >= 2 && it[0] == "e" && it[1] in carried } + } + } + merge(session.pinHeads.map { }, session.state.map { }, session.controlDrained.map { }, evidence.map { }) + .conflate() + .collectLatest { + // Re-read, then sleep until the next pinned message expires: an expired one + // leaves the list, and nothing else would trigger that re-read. A new trigger + // cancels the wait. + while (true) { + val pins = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) } + value = pins + val now = TimeUtils.now() + val next = pins?.nextExpiry(now) ?: break + delay((next - now) * 1000 + 250) + } + } } - merge(session.pinHeads.map { }, session.state.map { }, evidence.map { }).collect { - value = withContext(Dispatchers.Default) { account.concord.concordChannelPins(communityId, channelId) } - } } } +/** + * [pins] as a reader should see them: entries by a banned author (CORD-04 §4 — every client declines + * to show their posts) or by someone this account mutes or blocks are left out. + */ +@Composable +fun rememberVisibleConcordPins( + communityId: String, + pins: ConcordChannelPins, + accountViewModel: AccountViewModel, +): List { + val account = accountViewModel.account + val revision by account.concordSessions.revision.collectAsStateWithLifecycle() + val hidden by account.hiddenUsers.flow.collectAsStateWithLifecycle() + return remember(pins, revision, hidden) { + val authority = + account.concordSessions + .sessionFor(communityId) + ?.state + ?.value + ?.authority + ConcordPinning.visible(pins, isBanned = { authority?.isBanned(it) == true }, isHidden = { account.isHidden(it) }) + } +} + +/** + * The Pin action on a disappearing message (one carrying a CORD-08 `expiration`) asks first: the pin + * carries the message's words in its proof, so it keeps them readable after the timer erased the + * message everywhere else. + */ +@Composable +fun ConcordExpiringPinDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(Res.string.concord_pin_expiring_title)) }, + text = { Text(stringRes(Res.string.concord_pin_expiring_body)) }, + confirmButton = { TextButton(onClick = onConfirm) { Text(stringRes(Res.string.concord_pin_expiring_confirm)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } }, + ) +} + /** * 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 @@ -139,14 +225,18 @@ fun ConcordPinDuties( /** The channel header's pinned-messages entry point: a pin with a count badge. Hidden when there is nothing to show. */ @Composable fun ConcordPinnedButton( + communityId: String, pins: ConcordChannelPins?, + accountViewModel: AccountViewModel, onClick: () -> Unit, ) { - if (pins == null || (pins.count == 0 && !pins.sealedUnavailable)) return + if (pins == null) return + val count = rememberVisibleConcordPins(communityId, pins, accountViewModel).size + if (count == 0 && !pins.sealedUnavailable) return IconButton(onClick = onClick) { BadgedBox( badge = { - if (pins.count > 0) Badge { Text(pins.count.toString()) } + if (count > 0) Badge { Text(count.toString()) } }, ) { Icon(symbol = MaterialSymbols.PushPin, contentDescription = stringRes(Res.string.relay_group_pinned_content_description)) @@ -170,7 +260,11 @@ fun ConcordPinnedMessagesSheet( onDismiss: () -> Unit, ) { val canPin = remember(pins) { accountViewModel.account.concord.canPinConcord(communityId) } - val session = remember(communityId) { accountViewModel.account.concordSessions.sessionFor(communityId) } + val revision by accountViewModel.account.concordSessions.revision + .collectAsStateWithLifecycle() + val session = remember(communityId, revision) { accountViewModel.account.concordSessions.sessionFor(communityId) } + // Banned authors and muted/blocked users stay out of the sheet, as they do from the feed. + val shown = rememberVisibleConcordPins(communityId, pins, accountViewModel) ModalBottomSheet( onDismissRequest = onDismiss, @@ -198,7 +292,7 @@ fun ConcordPinnedMessagesSheet( modifier = Modifier.padding(horizontal = 16.dp), ) } - if (pins.count > 0) { + if (shown.isNotEmpty()) { Text( text = stringRes(Res.string.concord_pinned_open_hint), style = MaterialTheme.typography.labelSmall, @@ -210,12 +304,13 @@ fun ConcordPinnedMessagesSheet( if (pins.sealedUnavailable) { PinNotice(Res.string.concord_pinned_unavailable, MaterialSymbols.Lock) - } else if (pins.count == 0) { + } else if (shown.isEmpty() && pins.complete) { + // Only a drained fold may say "no pins"; before that the list may simply not be served yet. PinNotice(Res.string.concord_pinned_empty, MaterialSymbols.PushPin) } LazyColumn { - items(pins.pins, key = { it.rumorId }) { pinned -> + items(shown, key = { it.rumorId }) { pinned -> val jumpable = remember(pinned.rumorId, session) { session?.holdsRumor(pinned.rumorId) == true } PinnedRow( pinned = pinned, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt index 316dc9aa9a..d78778c90c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt @@ -106,10 +106,13 @@ object ConcordPins { val sealedUnavailable: Boolean, /** True when the content broke a cap or the format, so every reader treats it as empty. */ val violating: Boolean, + /** True when the content is the sealed form (`{"epoch","sealed"}`) — the same answer as [isSealedForm]. */ + val sealedForm: Boolean = false, ) { companion object { val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false) val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true) + val VIOLATING_SEALED = PinListRead(emptyList(), sealedUnavailable = false, violating = true, sealedForm = true) } } @@ -137,18 +140,23 @@ object ConcordPins { val entries = root["entries"] if (entries != null) return entriesOf(entries) + // From here the content is the sealed form exactly when [isSealedForm] says so — reported on + // the read so a caller need not parse the content a second time. + val sealedForm = root["sealed"] != null + val violating = if (sealedForm) PinListRead.VIOLATING_SEALED else PinListRead.VIOLATING val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content - if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING - val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING - val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false) + if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return violating + val epochValue = epoch.toLongOrNull() ?: return violating + val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false, sealedForm = true) val inner = try { parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject } catch (_: Exception) { null - } ?: return PinListRead.VIOLATING - return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING) + } ?: return violating + val read = entriesOf(inner["entries"] ?: return violating) + return PinListRead(read.entries, read.sealedUnavailable, read.violating, sealedForm = true) } private fun entriesOf(element: JsonElement): PinListRead { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt index 7aaf8510e3..9d365e0a35 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt @@ -167,6 +167,12 @@ class ConcordPinsTest { val noKey = ConcordPins.read(sealed) { null } assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it") assertTrue(noKey.entries.isEmpty()) + + // The read reports the form itself, matching isSealedForm, so nobody parses twice. + for (content in listOf(sealed, ConcordPins.serializePublic(listOf(entry)), "not json", """{"sealed":1}""", """{"epoch":"x","sealed":"y"}""")) { + assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { plane.conversationKey }.sealedForm, content) + assertEquals(ConcordPins.isSealedForm(content), ConcordPins.read(content) { null }.sealedForm, content) + } } @Test