diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 6e0ae12b6e..43bb84c9a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent @@ -170,6 +171,7 @@ private object PrefKeys { const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList" + const val LATEST_CONCORD_LIST = "latestConcordList" const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList" const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList" @@ -581,6 +583,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList) putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList) putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList) + putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList) putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList) putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList) putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList) @@ -769,6 +772,7 @@ object LocalPreferences { val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null) val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null) val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null) + val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null) val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null) val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null) val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null) @@ -831,6 +835,7 @@ object LocalPreferences { val latestGeohashList = async { parseEventOrNull(latestGeohashListStr) } val latestEphemeralList = async { parseEventOrNull(latestEphemeralListStr) } val latestRelayGroupList = async { parseEventOrNull(latestRelayGroupListStr) } + val latestConcordList = async { parseEventOrNull(latestConcordListStr) } val latestTrustProviderList = async { parseEventOrNull(latestTrustProviderListStr) } val latestKeyPackageRelayList = async { parseEventOrNull(latestKeyPackageRelayListStr) } val latestFavoriteAlgoFeedsList = async { parseEventOrNull(latestFavoriteAlgoFeedsListStr) } @@ -885,6 +890,7 @@ object LocalPreferences { val latestGeohashListResolved = latestGeohashList.await() val latestEphemeralListResolved = latestEphemeralList.await() val latestRelayGroupListResolved = latestRelayGroupList.await() + val latestConcordListResolved = latestConcordList.await() val latestTrustProviderListResolved = latestTrustProviderList.await() val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await() val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await() @@ -981,6 +987,7 @@ object LocalPreferences { backupGeohashList = latestGeohashListResolved, backupEphemeralChatList = latestEphemeralListResolved, backupRelayGroupList = latestRelayGroupListResolved, + backupConcordList = latestConcordListResolved, backupTrustProviderList = latestTrustProviderListResolved, backupKeyPackageRelayList = latestKeyPackageRelayListResolved, backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index e1808649a8..0183867e41 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -77,7 +77,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmar import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch class AccountFeedContentStates( @@ -201,6 +203,26 @@ class AccountFeedContentStates( } } + // A Concord control-plane fold is what first reveals a community's channels (and what makes + // ConcordCommunitySession.state non-null, without which ChatroomListKnownFeedFilter emits + // nothing at all for that community). None of it flows through LocalCache.newEventBundles, + // so the additive path can't see it: a folded channel reaches the Messages tab only if a + // message for it happens to arrive afterwards. Cold boot therefore shows a *subset* of a + // community's channels, or omits a quiet community entirely, until some unrelated + // invalidation fires. Rebuild on every structural change instead. `revision` bumps only on + // fold/membership/rekey (never a plain message), and sample() coalesces the burst of folds + // that lands as each control plane catches up — the same pairing Account.kt uses to drive + // refreshConcordChannelIndex off this flow. + scope.launch(Dispatchers.IO) { + @OptIn(FlowPreview::class) + account.concordSessions.revision + .drop(1) + .sample(500) + .collect { + dmKnown.invalidateData() + } + } + // Same for the Concord view mode (inline channels vs one row per community). scope.launch(Dispatchers.IO) { account.settings.concordViewMode diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 9bb65aef67..49acfd9b1e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -62,8 +62,17 @@ enum class ConcordIngestOutcome { * a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */ NON_STRUCTURAL, - /** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a - * guestbook membership change, or a buffered base-rekey. Bumps the revision. */ + /** Ours and re-folded the Control Plane. The fold republishes [ConcordCommunitySession.state], + * so the session's own state watcher is what bumps the revision — and, because the folded state + * compares by value, only when the fold actually *changed* something. A control wrap that folds + * to an identical state (a prior-epoch wrap that doesn't move the anti-rollback floor, a role + * edition that touches nothing we subscribe on) therefore costs no bump at all. The manager must + * NOT bump on this outcome as well, or every control wrap counts twice. */ + STRUCTURAL_FOLD, + + /** Ours and changed structure *without* touching [ConcordCommunitySession.state]: a guestbook + * membership change (which republishes `members`) or a buffered base-rekey. No state watcher + * covers these, so the manager bumps the revision directly. */ STRUCTURAL, ; @@ -145,6 +154,22 @@ class ConcordCommunitySession( // Deduped inbound wraps. private val controlWraps = LinkedHashMap() + /** + * Decrypted control editions memoized by wrap id. + * + * Both [refold] and [controlFloorsLocked] fold their WHOLE buffer on every inbound control + * wrap, and turning a wrap into an edition is a NIP-44 open + parse. Re-deriving them each + * time made a cold-boot backfill quadratic in decryptions — one measured boot did ~8.6k opens + * to ingest 93 control wraps for a single community. Memoizing makes it one open per wrap. + * + * Wrap ids are unique and a wrap only ever belongs to one plane (it is routed by `pubKey`), so + * a single id-keyed map is safe across the current and prior-epoch Control Planes even though + * they open under different keys. A wrap that fails to open caches `null` so it is not retried + * on every subsequent fold. The wrap buffers are only ever added to, so this tracks their + * lifetime exactly and needs no separate eviction. + */ + private val editionByWrapId = HashMap() + // Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these // never join the live fold, they only produce the anti-rollback floor. private val historicalControlWraps = HashMap>() @@ -280,7 +305,7 @@ class ConcordCommunitySession( fun auxStreamKeys(): List = listOf(guestbookKey, nextBaseRekeyKey) /** The community's current Control Plane editions — the input a moderation edition chains onto. */ - fun controlEditions(): List = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) } + fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) } /** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */ fun controlPlaneWraps(): List = lock.withLock { controlWraps.values.toList() } @@ -314,7 +339,7 @@ class ConcordCommunitySession( if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup } refold() - return ConcordIngestOutcome.STRUCTURAL + return ConcordIngestOutcome.STRUCTURAL_FOLD } guestbookAddress -> { lock.withLock { @@ -341,7 +366,7 @@ class ConcordCommunitySession( if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup } refold() - return ConcordIngestOutcome.STRUCTURAL + return ConcordIngestOutcome.STRUCTURAL_FOLD } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } if (current != null) { @@ -422,7 +447,7 @@ class ConcordCommunitySession( val wraps = controlWraps.values.toList() val folded = ConcordCommunityState.fold( - ConcordActions.controlEditions(wraps, controlPlaneKey), + editionsLocked(wraps, controlPlaneKey), entry.owner, controlFloorsLocked(), ) @@ -455,6 +480,24 @@ class ConcordCommunitySession( for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } + /** + * [wraps] opened into editions through [editionByWrapId], so a wrap is only ever decrypted + * once no matter how many folds it participates in. Caller must hold [lock]. + */ + private fun editionsLocked( + wraps: Collection, + planeKey: GroupKey, + ): List = + wraps.mapNotNull { wrap -> + if (editionByWrapId.containsKey(wrap.id)) { + editionByWrapId[wrap.id] + } else { + val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) } + editionByWrapId[wrap.id] = edition + edition + } + } + /** * The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's * Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is @@ -472,7 +515,7 @@ class ConcordCommunitySession( var floors = emptyMap() for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) { val wraps = historicalControlWraps[address]?.values?.toList() ?: continue - val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first) + val editions = editionsLocked(wraps, keyAtEpoch.first) if (editions.isEmpty()) continue floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index bd2775eef2..0bb8faba0f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -150,6 +150,9 @@ class ConcordSessionManager( seenOnRelays: Set = emptySet(), ): Boolean { val outcome = registry.ingest(wrap, seenOnRelays) + // Only the planes that change structure *without* republishing `state`. A control-plane + // fold returns STRUCTURAL_FOLD and is bumped by the per-session state watcher instead, which + // (since the folded state compares by value) fires only when the fold genuinely changed. if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision() return outcome.claimed } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt index bc6eae3baf..9945c77fd0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt @@ -161,7 +161,14 @@ class FeedContentState( if (noteEvent != null) { !cacheProvider.hasBeenDeleted(noteEvent) } else { - false + // An event-less row is a placeholder the filter synthesized for a room + // that has no message yet — a just-joined Concord channel, NIP-29 group, + // Marmot group or geohash cell. It carries no event, so it cannot have + // been deleted, and dropping it here deleted every such row from the + // Messages list the moment ANY kind-5 landed in an unrelated batch. The + // row then stayed gone until the next full rebuild, which is why a quiet + // community looked like it had never loaded at all. + true } }.toImmutableList() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 0c0afcfd84..7f7882a14d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -108,7 +108,7 @@ class ConcordCommunitySessionTest { // Feed the genesis control wraps → state folds, channels + membership resolve. A fold is // STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision. - community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) } + community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, session.ingest(it)) } val state = session.state.value assertEquals("Nostrichs", state?.metadata?.name) assertTrue(state!!.channels.containsKey(community.generalChannelIdHex)) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt index e292d55451..0419520238 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistryTest.kt @@ -70,7 +70,7 @@ class ConcordSessionRegistryTest { assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex)) // A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL). - alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) } + alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) } val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value assertEquals("Alpha", alphaState?.metadata?.name) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index da4b6c817a..31c74caa7e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord04Roles.asFloor /** A channel id paired with its current folded definition. */ -class ConcordChannel( +data class ConcordChannel( val channelIdHex: String, val definition: ChannelEntity, ) @@ -49,7 +49,7 @@ class ConcordChannel( * "Every member keeps the entire Control Plane in sync — it is small and must * stay complete." Recompute this whenever the known editions change. */ -class ConcordCommunityState( +data class ConcordCommunityState( val ownerPubKey: String, val metadata: MetadataEntity?, val channels: Map, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 8f67a24b5a..04fa0176c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never * touch the owner can never bootstrap themselves. */ -class AuthorityResolver private constructor( +data class AuthorityResolver private constructor( private val ownerLower: String, private val roles: Map, private val memberRoles: Map>, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index adacb499e6..71696ba8c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -64,7 +64,7 @@ object ConcordJson { * drops the role, and with it every authority (grant) that depends on it. */ @Serializable -class RoleScope( +data class RoleScope( val kind: String = "server", @SerialName("channel_id") val channelId: String? = null, ) @@ -75,7 +75,7 @@ class RoleScope( * ranks higher; no role may claim position 0 (reserved for the owner). */ @Serializable -class RoleEntity( +data class RoleEntity( val name: String = "", val position: Long = 0, /** u64 permission bitfield as a decimal string. */ @@ -94,7 +94,7 @@ class RoleEntity( * terminates at the owner (see [AuthorityResolver]). */ @Serializable -class GrantEntity( +data class GrantEntity( val member: String = "", @SerialName("role_ids") val roleIds: List = emptyList(), ) @@ -105,7 +105,7 @@ class GrantEntity( * A [deleted] channel is terminal — its id is never reused. */ @Serializable -class ChannelEntity( +data class ChannelEntity( val name: String = "", val private: Boolean = false, val voice: Boolean = false, @@ -122,7 +122,7 @@ class ChannelEntity( * community name too. */ @Serializable -class MetadataEntity( +data class MetadataEntity( val name: String = "", val icon: ImagePointer? = null, val banner: ImagePointer? = null,