mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+29
-3
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
@@ -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 {
|
||||
|
||||
+55
-2
@@ -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>
|
||||
|
||||
+110
-15
@@ -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,
|
||||
|
||||
+13
-5
@@ -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 {
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user