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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
Claude
2026-09-29 19:35:50 +00:00
parent e17fdecf68
commit 2230358c10
9 changed files with 234 additions and 30 deletions
@@ -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<Boolean?>(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 },
)
}
}
}
}
@@ -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(
+2 -2
View File
@@ -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. |
@@ -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 {
@@ -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<HexKey> 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<HexKey, ConcordPins.PinListRead>()
/** 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<VerifiedPin>()
val killed = ArrayList<VerifiedPin>()
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<ConcordPinnedMessage> = 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,
@@ -619,6 +619,9 @@
<string name="concord_pinned_unavailable">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.</string>
<string name="concord_pinned_budget">%1$d of %2$d pins · %3$d% of the size budget used</string>
<string name="concord_pinned_open_hint">Tap a pin to jump to it</string>
<string name="concord_pin_expiring_title">Pin a disappearing message?</string>
<string name="concord_pin_expiring_body">This message is set to disappear. Pinning it keeps its words in the channel's pin list after the timer erases the message itself.</string>
<string name="concord_pin_expiring_confirm">Pin anyway</string>
<string name="concord_pin_failed_title">Pins</string>
<string name="concord_pin_failed_unavailable">The pinned list is sealed under a key you don't hold. Changing it now would drop pins you can't see.</string>
<string name="concord_pin_failed_too_many">This channel already has 25 pins. Unpin one first.</string>
@@ -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<ConcordChannelPins?> {
val account = accountViewModel.account
return produceState<ConcordChannelPins?>(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<ConcordPinnedMessage> {
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,
@@ -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 {
@@ -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